diff --git a/.gitattributes b/.gitattributes index adc4144ffa3..13825940056 100644 --- a/.gitattributes +++ b/.gitattributes @@ -3,3 +3,7 @@ go.sum linguist-generated text gnovm/stdlibs/generated.go linguist-generated gnovm/tests/stdlibs/generated.go linguist-generated +*.gen.gno linguist-generated +*.gen_test.gno linguist-generated +*.gen.go linguist-generated +*.gen_test.go linguist-generated \ No newline at end of file diff --git a/.github/.editorconfig b/.github/.editorconfig new file mode 100644 index 00000000000..751cd705457 --- /dev/null +++ b/.github/.editorconfig @@ -0,0 +1,8 @@ +# Make sure this is the top-level editorconfig +# https://editorconfig.org/ +root = true + +# GitHub Actions Workflows +[workflows/**.yml] +indent_style = space +indent_size = 2 diff --git a/.github/codecov.yml b/.github/codecov.yml index d1ecba7ade3..f0cb9583cf2 100644 --- a/.github/codecov.yml +++ b/.github/codecov.yml @@ -4,7 +4,7 @@ codecov: wait_for_ci: true comment: - require_changes: false + require_changes: true coverage: round: down @@ -13,7 +13,7 @@ coverage: project: default: target: auto - threshold: 10 # Let's decrease this later. + threshold: 5 # Let's decrease this later. base: parent if_no_uploads: error if_not_found: success @@ -22,12 +22,12 @@ coverage: patch: default: target: auto - threshold: 10 # Let's decrease this later. + threshold: 5 # Let's decrease this later. base: auto if_no_uploads: error if_not_found: success if_ci_failed: error - only_pulls: false + only_pulls: true # Only check patch coverage on PRs flag_management: default_rules: diff --git a/.github/golangci.yml b/.github/golangci.yml index b8bd5537135..afc581d2ec5 100644 --- a/.github/golangci.yml +++ b/.github/golangci.yml @@ -44,9 +44,11 @@ linters: linters-settings: gofmt: simplify: true + goconst: min-len: 3 min-occurrences: 3 + gosec: excludes: - G204 # Subprocess launched with a potential tainted input or cmd arguments @@ -56,6 +58,7 @@ linters-settings: checks: [ "all", "-ST1022", "-ST1003" ] errorlint: asserts: false + gocritic: enabled-tags: - diagnostic @@ -63,6 +66,7 @@ linters-settings: - opinionated - performance - style + forbidigo: forbid: - p: '^regexp\.(Match|MatchString)$' @@ -74,12 +78,14 @@ issues: max-same-issues: 0 new: false fix: false + exclude-rules: - path: _test\.go linters: - gosec # Disabled linting of weak number generators - makezero # Disabled linting of intentional slice appends - goconst # Disabled linting of common mnemonics and test case strings + - unused # Disabled linting of unused mock methods - path: _\.gno linters: - errorlint # Disabled linting of error comparisons, because of lacking std lib support diff --git a/.github/workflows/auto-author-assign.yml b/.github/workflows/auto-author-assign.yml index 06dfb4ab903..890e70da9ae 100644 --- a/.github/workflows/auto-author-assign.yml +++ b/.github/workflows/auto-author-assign.yml @@ -1,4 +1,4 @@ -name: auto-author-assign +name: Auto Assign PR Author on: pull_request_target: diff --git a/.github/workflows/autocounterd.yml b/.github/workflows/autocounterd.yml index 9217fe2eef2..dcba56178bd 100644 --- a/.github/workflows/autocounterd.yml +++ b/.github/workflows/autocounterd.yml @@ -1,19 +1,13 @@ -name: autocounterd +name: Portal Loop - autocounterd on: - pull_request: - branches: - - master push: + branches: + - "master" paths: - misc/autocounterd - misc/loop - .github/workflows/autocounterd.yml - branches: - - "master" - - "misc/autocounterd" - tags: - - "v*" permissions: contents: read diff --git a/.github/workflows/benchmark-master-push.yml b/.github/workflows/benchmark-master-push.yml index 622baefc0de..1c054077a3a 100644 --- a/.github/workflows/benchmark-master-push.yml +++ b/.github/workflows/benchmark-master-push.yml @@ -1,14 +1,14 @@ -name: run benchmarks when pushing on main branch +name: Run and Save Benchmarks on: push: branches: - master paths: - - contribs/** - - gno.land/** - - gnovm/** - - tm2/** + - contribs/**/*.go + - gno.land/**/*.go + - gnovm/**/*.go + - tm2/**/*.go permissions: # deployments permission to deploy GitHub pages website @@ -22,7 +22,7 @@ env: jobs: benchmarks: if: ${{ github.repository == 'gnolang/gno' }} - runs-on: [self-hosted, Linux, X64, benchmarks] + runs-on: [ self-hosted, Linux, X64, benchmarks ] steps: - name: Checkout uses: actions/checkout@v4 diff --git a/.github/workflows/bot-proxy.yml b/.github/workflows/bot-proxy.yml new file mode 100644 index 00000000000..9bef0630d32 --- /dev/null +++ b/.github/workflows/bot-proxy.yml @@ -0,0 +1,48 @@ +# This workflow must be kept in sync to some extent with bot.yml +name: GitHub Bot Proxy + +on: + # Watch for any completed run on bot.yml workflow + workflow_run: + workflows: [GitHub Bot] + types: [completed] + +jobs: + # This workflow monitors any run completed on the GitHub Bot workflow and + # checks if the event that triggered it is limited to read-only permissions + # (e.g 'pull_request_review' on a pull request opened from a fork). + # In this case, it reruns the GitHub Bot workflow using a 'workflow_dispatch' + # event, thereby allowing it to run with write permissions. + # + # Complete flow: + # 'pull_request_review' from fork on bot.yml (read-only) -> 'workflow_run' on bot-proxy.yml (write) -> 'workflow_dispatch' on bot.yml (write) + rerun-with-write-perm: + name: Rerun Bot with write permission + # Skip this workflow if the original event is not 'pull_request_review' + if: github.event.workflow_run.event == 'pull_request_review' + runs-on: ubuntu-latest + permissions: + actions: write + + steps: + - name: Download artifact from previous run + uses: actions/download-artifact@v4 + with: + name: pr-number + run-id: ${{ github.event.workflow_run.id }} + github-token: ${{ secrets.GITHUB_TOKEN }} + # Even if the artifact doesn't exist, do not mark the workflow as failed + # Useful if the 'pull_request_review' event was emitted by a PR opened + # from a branch on the main repo, so it has already been processed by + # the bot workflow, and no artifact has been uploaded. + continue-on-error: true + id: download + + - name: Send workflow_dispatch event to Github Bot + # Run only if an artifact was downloaded + if: steps.download.outcome == 'success' + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.event.workflow_run.repository.full_name }} + run: | + gh workflow run bot.yml -R "$REPO" -f "pull-request-list=$(cat pr-number)" diff --git a/.github/workflows/bot.yml b/.github/workflows/bot.yml index 300a5928e25..add800fe2bf 100644 --- a/.github/workflows/bot.yml +++ b/.github/workflows/bot.yml @@ -1,3 +1,4 @@ +# This workflow must be kept in sync to some extent with bot-proxy.yml name: GitHub Bot on: @@ -37,8 +38,14 @@ jobs: # handle the parallel processing of the pull-requests define-prs-matrix: name: Define PRs matrix - # Prevent bot from retriggering itself and ignore event emitted by codecov - if: ${{ github.actor != vars.GH_BOT_LOGIN && github.actor != 'codecov[bot]' }} + # Skip this workflow if: + # - the bot is retriggering itself + # - the event is emitted by codecov + # - the event is a review on a pull request from a fork (see save-pr-number job below) + if: | + github.actor != vars.GH_BOT_LOGIN && + github.actor != 'codecov[bot]' && + (github.event_name != 'pull_request_review' || github.event.pull_request.base.repo.full_name == github.event.pull_request.head.repo.full_name) runs-on: ubuntu-latest permissions: pull-requests: read @@ -61,13 +68,36 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: go run . matrix -matrix-key 'pr-numbers' -verbose + # This job is executed if an event with read-only permission has triggered this + # workflow (e.g 'pull_request_review' on a pull request opened from a fork). + # In this case, this job persists the PR number in an artifact so that the + # proxy workflow can use it to rerun the current workflow with write permission. + # See bot-proxy.yml for more info. + save-pr-number: + name: Persist PR number for proxy + # Run this job if the event is a review on a pull request opened from a fork + if: github.event_name == 'pull_request_review' && github.event.pull_request.base.repo.full_name != github.event.pull_request.head.repo.full_name + runs-on: ubuntu-latest + + steps: + - name: Write PR number to a file + env: + PR_NUMBER: ${{ github.event.pull_request.number }} + run: echo $PR_NUMBER > pr-number + + - name: Upload it as an artifact + uses: actions/upload-artifact@v4 + with: + name: pr-number + path: pr-number + # This job processes each pull request in the matrix individually while ensuring # that a same PR cannot be processed concurrently by mutliple runners process-pr: name: Process PR needs: define-prs-matrix # Just skip this job if PR numbers matrix is empty (prevent failed state) - if: ${{ needs.define-prs-matrix.outputs.pr-numbers != '[]' && needs.define-prs-matrix.outputs.pr-numbers != '' }} + if: needs.define-prs-matrix.outputs.pr-numbers != '[]' && needs.define-prs-matrix.outputs.pr-numbers != '' runs-on: ubuntu-latest strategy: matrix: diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index d2eef9d7445..4745788714d 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -9,13 +9,17 @@ # the `language` matrix defined below to confirm you have the correct set of # supported CodeQL languages. # -name: "CodeQL" +name: CodeQL on: push: branches: [ "master", "chain/*" ] pull_request: branches: [ "master", "chain/*" ] + paths: + - '**/*.go' + - 'go.mod' + - 'go.sum' schedule: - cron: '22 17 * * 3' @@ -41,8 +45,8 @@ jobs: fail-fast: false matrix: include: - - language: go - build-mode: autobuild + - language: go + build-mode: autobuild # CodeQL supports the following values keywords for 'language': 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'swift' # Use `c-cpp` to analyze code written in C, C++ or both # Use 'java-kotlin' to analyze code written in Java, Kotlin or both @@ -52,38 +56,38 @@ jobs: # If you are analyzing a compiled language, you can modify the 'build-mode' for that language to customize how # your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages steps: - - name: Checkout repository - uses: actions/checkout@v4 + - name: Checkout repository + uses: actions/checkout@v4 - # Initializes the CodeQL tools for scanning. - - name: Initialize CodeQL - uses: github/codeql-action/init@v3 - with: - languages: ${{ matrix.language }} - build-mode: ${{ matrix.build-mode }} - # If you wish to specify custom queries, you can do so here or in a config file. - # By default, queries listed here will override any specified in a config file. - # Prefix the list here with "+" to use these queries and those in the config file. + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: ${{ matrix.language }} + build-mode: ${{ matrix.build-mode }} + # If you wish to specify custom queries, you can do so here or in a config file. + # By default, queries listed here will override any specified in a config file. + # Prefix the list here with "+" to use these queries and those in the config file. - # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs - # queries: security-extended,security-and-quality + # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs + # queries: security-extended,security-and-quality - # If the analyze step fails for one of the languages you are analyzing with - # "We were unable to automatically build your code", modify the matrix above - # to set the build mode to "manual" for that language. Then modify this step - # to build your code. - # ℹī¸ Command-line programs to run using the OS shell. - # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun - - if: matrix.build-mode == 'manual' - run: | - echo 'If you are using a "manual" build mode for one or more of the' \ - 'languages you are analyzing, replace this with the commands to build' \ - 'your code, for example:' - echo ' make bootstrap' - echo ' make release' - exit 1 + # If the analyze step fails for one of the languages you are analyzing with + # "We were unable to automatically build your code", modify the matrix above + # to set the build mode to "manual" for that language. Then modify this step + # to build your code. + # ℹī¸ Command-line programs to run using the OS shell. + # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun + - if: matrix.build-mode == 'manual' + run: | + echo 'If you are using a "manual" build mode for one or more of the' \ + 'languages you are analyzing, replace this with the commands to build' \ + 'your code, for example:' + echo ' make bootstrap' + echo ' make release' + exit 1 - - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v3 - with: - category: "/language:${{matrix.language}}" + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v3 + with: + category: "/language:${{matrix.language}}" diff --git a/.github/workflows/contribs.yml b/.github/workflows/contribs.yml index 3739339f7be..c1de5e78c35 100644 --- a/.github/workflows/contribs.yml +++ b/.github/workflows/contribs.yml @@ -1,30 +1,54 @@ -name: contribs +name: Contribs on: push: branches: - master - workflow_dispatch: pull_request: + paths: + - contribs/** + workflow_dispatch: jobs: setup: runs-on: ubuntu-latest outputs: programs: ${{ steps.set-matrix.outputs.programs }} + go-versions: ${{ steps.get-go-versions.outputs.go-versions }} steps: - uses: actions/checkout@v4 + - id: set-matrix - run: echo "::set-output name=programs::$(ls -d contribs/*/ | cut -d/ -f2 | jq -R -s -c 'split("\n")[:-1]')" + run: | + echo "::set-output name=programs::$(ls -d contribs/*/ | cut -d/ -f2 | jq -R -s -c 'split("\n")[:-1]')" + + - id: get-go-versions + run: | + contribs_programs=$(ls -d contribs/*/ | cut -d/ -f2) + versions_map="{" + + for p in $contribs_programs; do + # Fetch the go version of the contribs entry, and save it + # to a versions map we can reference later in the workflow + go_version=$(grep "^go [0-9]" contribs/$p/go.mod | cut -d ' ' -f2) + versions_map="$versions_map\"$p\":\"$go_version\"," + done + + # Close out the JSON + versions_map="${versions_map%,}" + versions_map="$versions_map}" + echo "::set-output name=go-versions::$versions_map" + main: needs: setup strategy: - fail-fast: false - matrix: - program: ${{ fromJson(needs.setup.outputs.programs) }} + fail-fast: false + matrix: + program: ${{ fromJson(needs.setup.outputs.programs) }} name: Run Main uses: ./.github/workflows/main_template.yml with: modulepath: contribs/${{ matrix.program }} + go-version: ${{ (fromJson(needs.setup.outputs.go-versions))[matrix.program] }} secrets: codecov-token: ${{ secrets.CODECOV_TOKEN }} diff --git a/.github/workflows/dependabot-validate.yml b/.github/workflows/dependabot-validate.yml index b1387dc0bb2..3d7b2c315c6 100644 --- a/.github/workflows/dependabot-validate.yml +++ b/.github/workflows/dependabot-validate.yml @@ -1,10 +1,11 @@ -name: dependabot validate +name: Validate Dependabot Config on: pull_request: paths: - '.github/dependabot.yml' - '.github/workflows/dependabot-validate.yml' + jobs: validate: runs-on: ubuntu-latest diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml index d800147a498..f180f1679b1 100644 --- a/.github/workflows/deploy-docs.yml +++ b/.github/workflows/deploy-docs.yml @@ -1,4 +1,6 @@ -name: deploy docs on gnolang/docs.gno.land repository +# This workflow triggers a cross-repo workflow call, +# that deploys the monorepo docs on Netlify, using Docusaurus +name: Deploy the Documentation on: push: branches: diff --git a/.github/workflows/docs.yml b/.github/workflows/docs-linter.yml similarity index 95% rename from .github/workflows/docs.yml rename to .github/workflows/docs-linter.yml index c9d9af0fb6f..d603d796ae9 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs-linter.yml @@ -1,8 +1,8 @@ -name: "docs / lint" +name: Docs Linter on: push: - paths: + branches: - master pull_request: paths: diff --git a/.github/workflows/examples.yml b/.github/workflows/examples.yml index 41d579c4567..5d606a2a663 100644 --- a/.github/workflows/examples.yml +++ b/.github/workflows/examples.yml @@ -1,9 +1,14 @@ -name: examples +name: Gno Examples on: - pull_request: push: - branches: ["master"] + branches: + - master + pull_request: + paths: + - gnovm/**/*.gno + - examples/**/*.gno + - examples/**/gno.mod concurrency: group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} @@ -66,27 +71,25 @@ jobs: - run: make lint -C ./examples # TODO: consider running lint on every other directories, maybe in "warning" mode? # TODO: track coverage + fmt: - strategy: - fail-fast: false - matrix: - goversion: ["1.22.x"] - runs-on: ubuntu-latest - timeout-minutes: 10 - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-go@v5 - with: - go-version: ${{ matrix.goversion }} - - run: | - make fmt -C ./examples - # Check if there are changes after running make fmt - git diff --exit-code || (echo "Some gno files are not formatted, please run 'make fmt'." && exit 1) + name: Run gno fmt on examples + uses: ./.github/workflows/gnofmt_template.yml + with: + path: "examples/..." + + generate: + name: Check generated files are up to date + uses: ./.github/workflows/build_template.yml + with: + modulepath: "examples" + go-version: "1.22.x" + mod-tidy: strategy: fail-fast: false matrix: - go-version: ["1.22.x"] + go-version: [ "1.22.x" ] # unittests: TODO: matrix with contracts runs-on: ubuntu-latest timeout-minutes: 10 diff --git a/.github/workflows/fossa.yml b/.github/workflows/fossa.yml index 41d9a2cba94..0a94211cb90 100644 --- a/.github/workflows/fossa.yml +++ b/.github/workflows/fossa.yml @@ -1,6 +1,18 @@ name: Dependency License Scanning on: + push: + branches: + - master + paths: + - '**/*.go' + - 'go.mod' + - 'go.sum' + pull_request_target: + paths: + - '**/*.go' + - 'go.mod' + - 'go.sum' workflow_dispatch: permissions: diff --git a/.github/workflows/genesis-verify.yml b/.github/workflows/genesis-verify.yml index 1288d588100..acc41cc99ad 100644 --- a/.github/workflows/genesis-verify.yml +++ b/.github/workflows/genesis-verify.yml @@ -1,9 +1,10 @@ -name: genesis-verify +name: Deployment genesis.json Verification on: - pull_request: + push: branches: - master + pull_request: paths: - "misc/deployments/**/genesis.json" - ".github/workflows/genesis-verify.yml" @@ -13,7 +14,7 @@ jobs: strategy: fail-fast: false matrix: - testnet: ["test5.gno.land"] + testnet: [ "test5.gno.land" ] runs-on: ubuntu-latest steps: - name: Checkout code diff --git a/.github/workflows/gh-pages.yml b/.github/workflows/gh-pages.yml index 1b955b52cd0..a293469bb5d 100644 --- a/.github/workflows/gh-pages.yml +++ b/.github/workflows/gh-pages.yml @@ -1,9 +1,11 @@ -# generate docs and publish on gh-pages branch -name: gh-pages +# generate Go docs and publish on gh-pages branch +# Live at: https://gnolang.github.io/gno +name: Go Reference Docs Deployment on: push: - branches: [ "master" ] + branches: + - master workflow_dispatch: permissions: @@ -24,12 +26,19 @@ jobs: - uses: actions/setup-go@v5 with: go-version-file: go.mod + - run: echo "GOROOT=$(go env GOROOT)" >> $GITHUB_ENV + - run: echo $GOROOT + - run: "cd misc/stdlib_diff && make gen" - run: "cd misc/gendocs && make install gen" + - run: "mkdir -p pages_output/stdlib_diff" + - run: | + cp -r misc/gendocs/godoc/* pages_output/ + cp -r misc/stdlib_diff/stdlib_diff/* pages_output/stdlib_diff/ - uses: actions/configure-pages@v5 id: pages - uses: actions/upload-pages-artifact@v3 with: - path: ./misc/gendocs/godoc + path: ./pages_output deploy: if: ${{ github.repository == 'gnolang/gno' }} # Alternatively, validate based on provided tokens and permissions. diff --git a/.github/workflows/gnofmt_template.yml b/.github/workflows/gnofmt_template.yml index 1ba66d0fbe3..096dbaa1b5d 100644 --- a/.github/workflows/gnofmt_template.yml +++ b/.github/workflows/gnofmt_template.yml @@ -1,12 +1,15 @@ on: workflow_call: - inputs: - path: - required: true - type: string - go-version: - required: true - type: string + inputs: + path: + description: "Path to run gno fmt on" + required: true + type: string + go-version: + description: "Go version to use" + required: false + type: string + default: "1.22.x" jobs: fmt: @@ -16,9 +19,15 @@ jobs: uses: actions/setup-go@v5 with: go-version: ${{ inputs.go-version }} + - name: Checkout code uses: actions/checkout@v4 - - name: Fmt + + - name: Format code with gno fmt env: GNOFMT_PATH: ${{ inputs.path }} run: go run ./gnovm/cmd/gno fmt -v -diff $GNOFMT_PATH + + - name: Check for unformatted code + run: | + git diff --exit-code || (echo "Some gno files are not formatted, please run 'make fmt'." && exit 1) \ No newline at end of file diff --git a/.github/workflows/gnoland.yml b/.github/workflows/gnoland.yml index 4817e2db0e3..0d3a7a10516 100644 --- a/.github/workflows/gnoland.yml +++ b/.github/workflows/gnoland.yml @@ -4,15 +4,43 @@ on: push: branches: - master - workflow_dispatch: pull_request: + paths: + - gno.land/** + # We trigger the testing workflow for gno.land on the following, + # since there are integration suites that cover the gnovm / tm2 + - gnovm/** + - tm2/** + workflow_dispatch: jobs: main: - name: Run Main + name: Run gno.land suite uses: ./.github/workflows/main_template.yml with: modulepath: "gno.land" tests-extra-args: "-coverpkg=github.com/gnolang/gno/gno.land/..." secrets: codecov-token: ${{ secrets.CODECOV_TOKEN }} + + gnoweb_generate: + strategy: + fail-fast: false + matrix: + go-version: ["1.22.x"] + # unittests: TODO: matrix with contracts + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/setup-go@v5 + with: + go-version: ${{ matrix.go-version }} + - uses: actions/setup-node@v4 + with: + node-version: lts/Jod # (22.x) https://github.com/nodejs/Release + - uses: actions/checkout@v4 + - run: | + make -C gno.land/pkg/gnoweb fclean generate + # Check if there are changes after running generate.gnoweb + git diff --exit-code || \ + (echo "\`gnoweb generate\` out of date, please run \`make gnoweb.generate\` within './gno.land'" && exit 1) diff --git a/.github/workflows/gnovm.yml b/.github/workflows/gnovm.yml index 7e7586b23d9..7a015b74e09 100644 --- a/.github/workflows/gnovm.yml +++ b/.github/workflows/gnovm.yml @@ -1,23 +1,26 @@ -name: gnovm +name: GnoVM on: push: branches: - master - workflow_dispatch: pull_request: + paths: + - gnovm/** + - tm2/** # GnoVM has a dependency on TM2 types + workflow_dispatch: jobs: main: - name: Run Main + name: Run GnoVM suite uses: ./.github/workflows/main_template.yml with: modulepath: "gnovm" + tests-extra-args: "-coverpkg=github.com/gnolang/gno/gnovm/..." secrets: codecov-token: ${{ secrets.CODECOV_TOKEN }} fmt: - name: Run Gno Fmt + name: Run gno fmt on stdlibs uses: ./.github/workflows/gnofmt_template.yml with: path: "gnovm/stdlibs/..." - go-version: "1.22.x" diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml index 06b2daa1d3d..56075c31db3 100644 --- a/.github/workflows/labeler.yml +++ b/.github/workflows/labeler.yml @@ -1,6 +1,6 @@ -name: "Pull Request Labeler" +name: Pull Request Labeler on: -- pull_request_target + - pull_request_target jobs: triage: @@ -9,5 +9,5 @@ jobs: pull-requests: write runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: actions/labeler@v5 + - uses: actions/checkout@v4 + - uses: actions/labeler@v5 diff --git a/.github/workflows/lint-pr-title.yml b/.github/workflows/lint-pr-title.yml index 631f764c37f..3c7236b264f 100644 --- a/.github/workflows/lint-pr-title.yml +++ b/.github/workflows/lint-pr-title.yml @@ -1,4 +1,4 @@ -name: "lint-pr-title" +name: PR Title Linter on: pull_request_target: diff --git a/.github/workflows/lint_template.yml b/.github/workflows/lint_template.yml index b7568d19c41..43246572daa 100644 --- a/.github/workflows/lint_template.yml +++ b/.github/workflows/lint_template.yml @@ -8,7 +8,6 @@ on: required: true type: string - jobs: lint: runs-on: ubuntu-latest diff --git a/.github/workflows/main_template.yml b/.github/workflows/main_template.yml index 5b3437b54a1..a463bb330ea 100644 --- a/.github/workflows/main_template.yml +++ b/.github/workflows/main_template.yml @@ -1,41 +1,42 @@ on: - workflow_call: - inputs: - modulepath: - required: true - type: string - tests-extra-args: - required: false - type: string - secrets: - codecov-token: - required: true - -# TODO: environment variables cannot be sent to reusable workflows: https://docs.github.com/en/actions/using-workflows/reusing-workflows#limitations -# env: -# GO_VERSION: "1.22.x" + workflow_call: + inputs: + modulepath: + required: true + type: string + tests-extra-args: + required: false + type: string + go-version: + description: "Go version to use" + required: false + type: string + default: "1.22.x" + secrets: + codecov-token: + required: true jobs: - lint: - name: Go Linter - uses: ./.github/workflows/lint_template.yml - with: - modulepath: ${{ inputs.modulepath }} - go-version: "1.22.x" - build: - name: Go Build - uses: ./.github/workflows/build_template.yml - with: - modulepath: ${{ inputs.modulepath }} - go-version: "1.22.x" - test: - name: Go Test - uses: ./.github/workflows/test_template.yml - with: - modulepath: ${{ inputs.modulepath }} - tests-timeout: "30m" - go-version: "1.22.x" - tests-extra-args: ${{ inputs.tests-extra-args }} - secrets: - codecov-token: ${{ secrets.codecov-token }} + lint: + name: Go Lint + uses: ./.github/workflows/lint_template.yml + with: + modulepath: ${{ inputs.modulepath }} + go-version: ${{ inputs.go-version }} + build: + name: Go Build + uses: ./.github/workflows/build_template.yml + with: + modulepath: ${{ inputs.modulepath }} + go-version: ${{ inputs.go-version }} + test: + name: Go Test + uses: ./.github/workflows/test_template.yml + with: + modulepath: ${{ inputs.modulepath }} + tests-timeout: "30m" + go-version: ${{ inputs.go-version }} + tests-extra-args: ${{ inputs.tests-extra-args }} + secrets: + codecov-token: ${{ secrets.codecov-token }} diff --git a/.github/workflows/misc.yml b/.github/workflows/misc.yml index ad2c886e2ac..1116a87c300 100644 --- a/.github/workflows/misc.yml +++ b/.github/workflows/misc.yml @@ -6,8 +6,10 @@ on: push: branches: - master - workflow_dispatch: pull_request: + paths: + - misc/** + workflow_dispatch: jobs: main: @@ -21,9 +23,10 @@ jobs: - genstd - goscan - loop - name: Run Main + name: Run misc suite uses: ./.github/workflows/main_template.yml with: modulepath: misc/${{ matrix.program }} + tests-extra-args: "-coverpkg=github.com/gnolang/gno/misc/..." secrets: codecov-token: ${{ secrets.CODECOV_TOKEN }} diff --git a/.github/workflows/mod-tidy.yml b/.github/workflows/mod-tidy.yml index 24eab553d19..5b6401b0d13 100644 --- a/.github/workflows/mod-tidy.yml +++ b/.github/workflows/mod-tidy.yml @@ -1,11 +1,19 @@ -name: Ensure go.mods are tidied +name: go.mod Tidy Checker on: push: branches: - master - workflow_dispatch: + paths: + - '**/*.go' + - 'go.mod' + - 'go.sum' pull_request: + paths: + - '**/*.go' + - 'go.mod' + - 'go.sum' + workflow_dispatch: jobs: main: diff --git a/.github/workflows/portal-loop.yml b/.github/workflows/portal-loop.yml index b898a149e9d..b5cafa459a7 100644 --- a/.github/workflows/portal-loop.yml +++ b/.github/workflows/portal-loop.yml @@ -1,19 +1,13 @@ -name: portal-loop +name: Portal Loop on: - pull_request: - branches: - - master push: + branches: + - "master" + pull_request: paths: - "misc/loop/**" - ".github/workflows/portal-loop.yml" - branches: - - "master" - # NOTE(albttx): branch name to simplify tests for this workflow - - "ci/portal-loop" - tags: - - "v*" permissions: contents: read @@ -69,14 +63,14 @@ jobs: while block_height=$(curl -s localhost:26657/status | jq -r '.result.sync_info.latest_block_height') echo "Current block height: $block_height" - [[ "$block_height" -lt 10 ]] + [[ "$block_height" -lt 2 ]] do sleep 1 done curl -s localhost:26657/status | jq - - name: "Buid new gnolang/gno image" + - name: "Build new gnolang/gno image" run: | docker build -t ghcr.io/gnolang/gno/gnoland:master -f Dockerfile --target gnoland . @@ -96,7 +90,7 @@ jobs: while block_height=$(curl -s localhost:26657/status | jq -r '.result.sync_info.latest_block_height') echo "Current block height: $block_height" - [[ "$block_height" -lt 10 ]] + [[ "$block_height" -lt 2 ]] do sleep 5 done diff --git a/.github/workflows/releaser-master.yml b/.github/workflows/releaser-master.yml index 3d194e2cb4c..7c81789b060 100644 --- a/.github/workflows/releaser-master.yml +++ b/.github/workflows/releaser-master.yml @@ -1,9 +1,9 @@ -name: Trigger master build +name: Master Releases on: push: branches: - - "master" + - master workflow_dispatch: permissions: @@ -27,7 +27,7 @@ jobs: cache: true - uses: sigstore/cosign-installer@v3.7.0 - - uses: anchore/sbom-action/download-syft@v0.17.8 + - uses: anchore/sbom-action/download-syft@v0.17.9 - uses: docker/login-action@v3 with: diff --git a/.github/workflows/releaser-nightly.yml b/.github/workflows/releaser-nightly.yml index 4308f1c4a7d..47b6cabb223 100644 --- a/.github/workflows/releaser-nightly.yml +++ b/.github/workflows/releaser-nightly.yml @@ -1,4 +1,4 @@ -name: Trigger nightly build +name: Nightly Releases on: schedule: @@ -24,7 +24,7 @@ jobs: cache: true - uses: sigstore/cosign-installer@v3.7.0 - - uses: anchore/sbom-action/download-syft@v0.17.8 + - uses: anchore/sbom-action/download-syft@v0.17.9 - uses: docker/login-action@v3 with: diff --git a/.github/workflows/releaser.yml b/.github/workflows/releaser.yml deleted file mode 100644 index 309664bdcce..00000000000 --- a/.github/workflows/releaser.yml +++ /dev/null @@ -1,45 +0,0 @@ -name: Go Releaser - -on: - push: - tags: - - "v*" - -permissions: - contents: write # needed to write releases - id-token: write # needed for keyless signing - packages: write # needed for ghcr access - -jobs: - goreleaser: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - uses: actions/setup-go@v5 - with: - go-version-file: go.mod - cache: true - - - uses: sigstore/cosign-installer@v3.7.0 - - uses: anchore/sbom-action/download-syft@v0.17.8 - - - uses: docker/login-action@v3 - with: - registry: ghcr.io - username: ${{ github.repository_owner }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Set up QEMU - uses: docker/setup-qemu-action@v3 - - - uses: goreleaser/goreleaser-action@v6 - with: - distribution: goreleaser-pro - version: ~> v2 - args: release --clean --config ./.github/goreleaser.yaml - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - GORELEASER_KEY: ${{ secrets.GORELEASER_KEY }} diff --git a/.github/workflows/stale-bot.yml b/.github/workflows/stale-bot.yml index 55a17ac60a8..6eb38ac5728 100644 --- a/.github/workflows/stale-bot.yml +++ b/.github/workflows/stale-bot.yml @@ -1,4 +1,4 @@ -name: "Close stale PRs" +name: Stale PR Bot on: schedule: - cron: "30 1 * * *" diff --git a/.github/workflows/test_template.yml b/.github/workflows/test_template.yml index c7956b4caf4..a1bc58ecebb 100644 --- a/.github/workflows/test_template.yml +++ b/.github/workflows/test_template.yml @@ -1,84 +1,70 @@ on: - workflow_call: - inputs: - modulepath: - required: true - type: string - tests-timeout: - required: true - type: string - go-version: - required: true - type: string - tests-extra-args: - required: false - type: string - secrets: - codecov-token: - required: true + workflow_call: + inputs: + modulepath: + required: true + type: string + tests-timeout: + required: true + type: string + go-version: + required: true + type: string + tests-extra-args: + required: false + type: string + secrets: + codecov-token: + required: true jobs: - test: - runs-on: ubuntu-latest - steps: - - name: Checkout code - uses: actions/checkout@v4 - - name: Install Go - uses: actions/setup-go@v5 - with: - go-version: ${{ inputs.go-version }} - - name: Go test - working-directory: ${{ inputs.modulepath }} - env: - TXTARCOVERDIR: /tmp/txtarcoverdir # txtar cover output - GOCOVERDIR: /tmp/gocoverdir # go cover output - COVERDIR: /tmp/coverdir # final output - run: | - set -x # print commands + test: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + - name: Install Go + uses: actions/setup-go@v5 + with: + go-version: ${{ inputs.go-version }} + - name: Go test + working-directory: ${{ inputs.modulepath }} + env: + TXTARCOVERDIR: /tmp/txtarcoverdir # txtar cover output + GOCOVERDIR: /tmp/gocoverdir # go cover output + COVERDIR: /tmp/coverdir # final output + run: | + set -x # print commands + + mkdir -p "$GOCOVERDIR" "$TXTARCOVERDIR" "$COVERDIR" + + # Craft a filter flag based on the module path to avoid expanding coverage on unrelated tags. + export filter="-pkg=github.com/gnolang/gno/${{ inputs.modulepath }}/..." + + # codecov only supports "boolean" coverage (whether a line is + # covered or not); so using -covermode=count or atomic would be + # pointless here. + # XXX: Simplify coverage of txtar - the current setup is a bit + # confusing and meticulous. There will be some improvements in Go + # 1.23 regarding coverage, so we can use this as a workaround until + # then. + go test -covermode=set -timeout ${{ inputs.tests-timeout }} ${{ inputs.tests-extra-args }} ./... -test.gocoverdir=$GOCOVERDIR + + # Print results + (set +x; echo 'go coverage results:') + go tool covdata percent $filter -i=$GOCOVERDIR + (set +x; echo 'txtar coverage results:') + go tool covdata percent $filter -i=$TXTARCOVERDIR + + # Generate final coverage output + go tool covdata textfmt -v 1 $filter -i=$GOCOVERDIR,$TXTARCOVERDIR -o gocoverage.out - mkdir -p "$GOCOVERDIR" "$TXTARCOVERDIR" "$COVERDIR" - - # Craft a filter flag based on the module path to avoid expanding coverage on unrelated tags. - export filter="-pkg=github.com/gnolang/gno/${{ inputs.modulepath }}/..." - - # codecov only supports "boolean" coverage (whether a line is - # covered or not); so using -covermode=count or atomic would be - # pointless here. - # XXX: Simplify coverage of txtar - the current setup is a bit - # confusing and meticulous. There will be some improvements in Go - # 1.23 regarding coverage, so we can use this as a workaround until - # then. - go test -covermode=set -timeout ${{ inputs.tests-timeout }} ${{ inputs.tests-extra-args }} ./... -test.gocoverdir=$GOCOVERDIR - - # Print results - (set +x; echo 'go coverage results:') - go tool covdata percent $filter -i=$GOCOVERDIR - (set +x; echo 'txtar coverage results:') - go tool covdata percent $filter -i=$TXTARCOVERDIR - - # Generate final coverage output - go tool covdata textfmt -v 1 $filter -i=$GOCOVERDIR,$TXTARCOVERDIR -o gocoverage.out - - - name: Upload go coverage to Codecov - uses: codecov/codecov-action@v5 - with: - disable_search: true - fail_ci_if_error: true - files: ${{ inputs.modulepath }}/gocoverage.out - flags: ${{ inputs.modulepath }} - token: ${{ secrets.codecov-token }} - verbose: true # keep this enable as it help debugging when coverage fail randomly on the CI - - # TODO: We have to fix race conditions before running this job - # test-with-race: - # runs-on: ubuntu-latest - # steps: - # - name: Install Go - # uses: actions/setup-go@v5 - # with: - # go-version: ${{ inputs.go-version }} - # - name: Checkout code - # uses: actions/checkout@v4 - # - name: Go race test - # run: go test -race -timeout ${{ inputs.tests-timeout }} ./... - # working-directory: ${{ inputs.modulepath }} + - name: Upload go coverage to Codecov + uses: codecov/codecov-action@v5 + with: + disable_search: true + fail_ci_if_error: true + files: ${{ inputs.modulepath }}/gocoverage.out + flags: ${{ inputs.modulepath }} + token: ${{ secrets.codecov-token }} + verbose: true # keep this enable as it help debugging when coverage fails randomly on the CI diff --git a/.github/workflows/tm2.yml b/.github/workflows/tm2.yml index 57e84793c94..757391eab8c 100644 --- a/.github/workflows/tm2.yml +++ b/.github/workflows/tm2.yml @@ -1,17 +1,20 @@ -name: tm2 +name: TM2 on: push: branches: - master - workflow_dispatch: pull_request: + paths: + - tm2/** + workflow_dispatch: jobs: main: - name: Run Main + name: Run TM2 suite uses: ./.github/workflows/main_template.yml with: modulepath: "tm2" + tests-extra-args: "-coverpkg=github.com/gnolang/gno/tm2/..." secrets: codecov-token: ${{ secrets.CODECOV_TOKEN }} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index bc125a6da73..b58d63c6c75 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -469,6 +469,18 @@ Resources for idiomatic Go docs: - [godoc](https://go.dev/blog/godoc) - [Go Doc Comments](https://tip.golang.org/doc/comment) +## Avoding Unhelpful Contributions + +While we welcome all contributions to the Gno project, it's important to ensure that your changes provide meaningful value or improve the quality of the codebase. Contributions that fail to meet these criteria may not be accepted. Examples of unhelpful contributions include (but not limited to): + +- Airdrop farming & karma farming: Making minimal, superficial changes, with the goal of becoming eligible for airdrops and GovDAO participation. +- Incomplete submissions: Changes that lack adequate context, link to a related issue, documentation, or test coverage. + +Before submitting a pull request, ask yourself: +- Does this change solve a specific problem or add clear value? +- Is the implementation aligned with the gno.land's goals and style guide? +- Have I tested my changes and included relevant documentation? + ## Additional Notes ### Issue and Pull Request Labels @@ -502,3 +514,4 @@ automatic label management. | info needed | Issue is lacking information needed for resolving | | investigating | Issue is still being investigated by the team | | question | Issue starts a discussion or raises a question | + diff --git a/Dockerfile b/Dockerfile index b858589640f..effc30ca32f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -52,7 +52,6 @@ ENTRYPOINT ["/usr/bin/gno"] # gnoweb FROM base AS gnoweb COPY --from=build-gno /gnoroot/build/gnoweb /usr/bin/gnoweb -COPY --from=build-gno /opt/gno/src/gno.land/cmd/gnoweb /opt/gno/src/gnoweb EXPOSE 8888 ENTRYPOINT ["/usr/bin/gnoweb"] diff --git a/contribs/github-bot/internal/config/config.go b/contribs/github-bot/internal/config/config.go index fd29f5e5f57..f80fc86cb11 100644 --- a/contribs/github-bot/internal/config/config.go +++ b/contribs/github-bot/internal/config/config.go @@ -24,7 +24,7 @@ type ManualCheck struct { // This is the description for a persistent rule with a non-standard behavior // that allow maintainer to force the "success" state of the CI check -const ForceSkipDescription = "**SKIP**: Do not block the CI for this PR" +const ForceSkipDescription = "**IGNORE** the bot requirements for this PR (force green CI check)" // This function returns the configuration of the bot consisting of automatic and manual checks // in which the GitHub client is injected. @@ -35,25 +35,25 @@ func Config(gh *client.GitHub) ([]AutomaticCheck, []ManualCheck) { If: c.CreatedFromFork(), Then: r.MaintainerCanModify(), }, - { - Description: "The pull request head branch must be up-to-date with its base ([more info](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/keeping-your-pull-request-in-sync-with-the-base-branch))", - If: c.Always(), - Then: r.UpToDateWith(gh, r.PR_BASE), - }, { Description: "Changes to 'docs' folder must be reviewed/authored by at least one devrel and one tech-staff", If: c.FileChanged(gh, "^docs/"), - Then: r.Or( - r.And( - r.AuthorInTeam(gh, "devrels"), + Then: r.And( + r.Or( + r.AuthorInTeam(gh, "tech-staff"), r.ReviewByTeamMembers(gh, "tech-staff", 1), ), - r.And( - r.AuthorInTeam(gh, "tech-staff"), + r.Or( + r.AuthorInTeam(gh, "devrels"), r.ReviewByTeamMembers(gh, "devrels", 1), ), ), }, + { + Description: "Must not contain the \"don't merge\" label", + If: c.Label("don't merge"), + Then: r.Never(), + }, } manual := []ManualCheck{ @@ -64,8 +64,11 @@ func Config(gh *client.GitHub) ([]AutomaticCheck, []ManualCheck) { }, { Description: "The pull request description provides enough details", - If: c.Not(c.AuthorInTeam(gh, "core-contributors")), - Teams: Teams{"core-contributors"}, + If: c.And( + c.Not(c.AuthorInTeam(gh, "core-contributors")), + c.Not(c.Author("dependabot[bot]")), + ), + Teams: Teams{"core-contributors"}, }, { Description: "Determine if infra needs to be updated before merging", diff --git a/contribs/gnodev/cmd/gnobro/main.go b/contribs/gnodev/cmd/gnobro/main.go index 092a441542a..91713d6c6d8 100644 --- a/contribs/gnodev/cmd/gnobro/main.go +++ b/contribs/gnodev/cmd/gnobro/main.go @@ -429,14 +429,14 @@ func getSignerForAccount(io commands.IO, address string, kb keys.Keybase, cfg *b } // try empty password first - if _, err := kb.ExportPrivKeyUnsafe(address, ""); err != nil { + if _, err := kb.ExportPrivKey(address, ""); err != nil { prompt := fmt.Sprintf("[%.10s] Enter password:", address) signer.Password, err = io.GetPassword(prompt, true) if err != nil { return nil, fmt.Errorf("error while reading password: %w", err) } - if _, err := kb.ExportPrivKeyUnsafe(address, signer.Password); err != nil { + if _, err := kb.ExportPrivKey(address, signer.Password); err != nil { return nil, fmt.Errorf("invalid password: %w", err) } } diff --git a/contribs/gnodev/cmd/gnodev/main.go b/contribs/gnodev/cmd/gnodev/main.go index 082d0cb8270..95f1d95e0a6 100644 --- a/contribs/gnodev/cmd/gnodev/main.go +++ b/contribs/gnodev/cmd/gnodev/main.go @@ -57,9 +57,10 @@ type devCfg struct { txsFile string // Web Configuration + noWeb bool + webHTML bool webListenerAddr string webRemoteHelperAddr string - webWithHTML bool // Node Configuration minimal bool @@ -123,6 +124,20 @@ func (c *devCfg) RegisterFlags(fs *flag.FlagSet) { "gno root directory", ) + fs.BoolVar( + &c.noWeb, + "no-web", + defaultDevOptions.noWeb, + "disable gnoweb", + ) + + fs.BoolVar( + &c.webHTML, + "web-html", + defaultDevOptions.webHTML, + "gnoweb: enable unsafe HTML parsing in markdown rendering", + ) + fs.StringVar( &c.webListenerAddr, "web-listener", @@ -137,13 +152,6 @@ func (c *devCfg) RegisterFlags(fs *flag.FlagSet) { "gnoweb: web server help page's remote addr (default to )", ) - fs.BoolVar( - &c.webWithHTML, - "web-with-html", - defaultDevOptions.webWithHTML, - "gnoweb: enable HTML parsing in markdown rendering", - ) - fs.StringVar( &c.nodeRPCListenerAddr, "node-rpc-listener", @@ -323,7 +331,10 @@ func execDev(cfg *devCfg, args []string, io commands.IO) (err error) { defer server.Close() // Setup gnoweb - webhandler := setupGnoWebServer(logger.WithGroup(WebLogName), cfg, devNode) + webhandler, err := setupGnoWebServer(logger.WithGroup(WebLogName), cfg, devNode) + if err != nil { + return fmt.Errorf("unable to setup gnoweb server: %w", err) + } // Setup unsafe APIs if enabled if cfg.unsafeAPI { @@ -351,14 +362,17 @@ func execDev(cfg *devCfg, args []string, io commands.IO) (err error) { mux.Handle("/", webhandler) } - go func() { - err := server.ListenAndServe() - cancel(err) - }() + // Serve gnoweb + if !cfg.noWeb { + go func() { + err := server.ListenAndServe() + cancel(err) + }() - logger.WithGroup(WebLogName). - Info("gnoweb started", - "lisn", fmt.Sprintf("http://%s", server.Addr)) + logger.WithGroup(WebLogName). + Info("gnoweb started", + "lisn", fmt.Sprintf("http://%s", server.Addr)) + } watcher, err := watcher.NewPackageWatcher(loggerEvents, emitterServer) if err != nil { @@ -377,7 +391,7 @@ func execDev(cfg *devCfg, args []string, io commands.IO) (err error) { return runEventLoop(ctx, logger, book, rt, devNode, watcher) } -var helper string = `For more in-depth documentation, visit the GNO Tooling CLI documentation: +var helper string = `For more in-depth documentation, visit the GNO Tooling CLI documentation: https://docs.gno.land/gno-tooling/cli/gno-tooling-gnodev P Previous TX - Go to the previous tx diff --git a/contribs/gnodev/cmd/gnodev/setup_web.go b/contribs/gnodev/cmd/gnodev/setup_web.go index d55814142a6..e509768d2a1 100644 --- a/contribs/gnodev/cmd/gnodev/setup_web.go +++ b/contribs/gnodev/cmd/gnodev/setup_web.go @@ -1,6 +1,7 @@ package main import ( + "fmt" "log/slog" "net/http" @@ -9,19 +10,25 @@ import ( ) // setupGnowebServer initializes and starts the Gnoweb server. -func setupGnoWebServer(logger *slog.Logger, cfg *devCfg, dnode *gnodev.Node) http.Handler { - webConfig := gnoweb.NewDefaultConfig() +func setupGnoWebServer(logger *slog.Logger, cfg *devCfg, dnode *gnodev.Node) (http.Handler, error) { + if cfg.noWeb { + return http.HandlerFunc(http.NotFound), nil + } + + remote := dnode.GetRemoteAddress() - webConfig.HelpChainID = cfg.chainId - webConfig.RemoteAddr = dnode.GetRemoteAddress() - webConfig.HelpRemote = cfg.webRemoteHelperAddr - webConfig.WithHTML = cfg.webWithHTML + appcfg := gnoweb.NewDefaultAppConfig() + appcfg.UnsafeHTML = cfg.webHTML + appcfg.NodeRemote = remote + appcfg.ChainID = cfg.chainId + if cfg.webRemoteHelperAddr != "" { + appcfg.RemoteHelp = cfg.webRemoteHelperAddr + } - // If `HelpRemote` is empty default it to `RemoteAddr` - if webConfig.HelpRemote == "" { - webConfig.HelpRemote = webConfig.RemoteAddr + router, err := gnoweb.NewRouter(logger, appcfg) + if err != nil { + return nil, fmt.Errorf("unable to create router app: %w", err) } - app := gnoweb.MakeApp(logger, webConfig) - return app.Router + return router, nil } diff --git a/contribs/gnodev/go.mod b/contribs/gnodev/go.mod index 2053a61db6c..92d8494fa40 100644 --- a/contribs/gnodev/go.mod +++ b/contribs/gnodev/go.mod @@ -1,6 +1,8 @@ module github.com/gnolang/gno/contribs/gnodev -go 1.22.0 +go 1.22 + +toolchain go1.22.4 replace github.com/gnolang/gno => ../.. @@ -27,7 +29,7 @@ require ( require ( dario.cat/mergo v1.0.1 // indirect - github.com/alecthomas/chroma/v2 v2.8.0 // indirect + github.com/alecthomas/chroma/v2 v2.14.0 // indirect github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect @@ -48,7 +50,7 @@ require ( github.com/creack/pty v1.1.21 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0 // indirect - github.com/dlclark/regexp2 v1.4.0 // indirect + github.com/dlclark/regexp2 v1.11.0 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/go-logfmt/logfmt v0.6.0 // indirect github.com/go-logr/logr v1.4.2 // indirect @@ -57,10 +59,6 @@ require ( github.com/golang/snappy v0.0.4 // indirect github.com/google/uuid v1.6.0 // indirect github.com/gorilla/css v1.0.0 // indirect - github.com/gorilla/mux v1.8.1 // indirect - github.com/gorilla/securecookie v1.1.1 // indirect - github.com/gorilla/sessions v1.2.1 // indirect - github.com/gotuna/gotuna v0.6.0 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.22.0 // indirect github.com/libp2p/go-buffer-pool v0.1.0 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect @@ -79,10 +77,12 @@ require ( github.com/rogpeppe/go-internal v1.12.0 // indirect github.com/rs/cors v1.11.1 // indirect github.com/rs/xid v1.6.0 // indirect + github.com/sig-0/insertion-queue v0.0.0-20241004125609-6b3ca841346b // indirect github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect - github.com/yuin/goldmark v1.5.4 // indirect + github.com/yuin/goldmark v1.7.2 // indirect github.com/yuin/goldmark-emoji v1.0.2 // indirect + github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc // indirect github.com/zondax/hid v0.9.2 // indirect github.com/zondax/ledger-go v0.14.3 // indirect go.etcd.io/bbolt v1.3.11 // indirect @@ -95,7 +95,7 @@ require ( go.opentelemetry.io/otel/trace v1.29.0 // indirect go.opentelemetry.io/proto/otlp v1.3.1 // indirect go.uber.org/multierr v1.11.0 // indirect - go.uber.org/zap/exp v0.2.0 // indirect + go.uber.org/zap/exp v0.3.0 // indirect golang.org/x/crypto v0.26.0 // indirect golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8 // indirect golang.org/x/mod v0.20.0 // indirect diff --git a/contribs/gnodev/go.sum b/contribs/gnodev/go.sum index f9250d34462..3f22e4f2f00 100644 --- a/contribs/gnodev/go.sum +++ b/contribs/gnodev/go.sum @@ -1,12 +1,14 @@ dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s= dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= github.com/aead/siphash v1.0.1/go.mod h1:Nywa3cDsYNNK3gaciGTWPwHt0wlpNV15vwmswBAUSII= -github.com/alecthomas/assert/v2 v2.2.1 h1:XivOgYcduV98QCahG8T5XTezV5bylXe+lBxLG2K2ink= -github.com/alecthomas/assert/v2 v2.2.1/go.mod h1:pXcQ2Asjp247dahGEmsZ6ru0UVwnkhktn7S0bBDLxvQ= -github.com/alecthomas/chroma/v2 v2.8.0 h1:w9WJUjFFmHHB2e8mRpL9jjy3alYDlU0QLDezj1xE264= -github.com/alecthomas/chroma/v2 v2.8.0/go.mod h1:yrkMI9807G1ROx13fhe1v6PN2DDeaR73L3d+1nmYQtw= -github.com/alecthomas/repr v0.2.0 h1:HAzS41CIzNW5syS8Mf9UwXhNH1J9aix/BvDRf1Ml2Yk= -github.com/alecthomas/repr v0.2.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= +github.com/alecthomas/assert/v2 v2.7.0 h1:QtqSACNS3tF7oasA8CU6A6sXZSBDqnm7RfpLl9bZqbE= +github.com/alecthomas/assert/v2 v2.7.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= +github.com/alecthomas/chroma/v2 v2.2.0/go.mod h1:vf4zrexSH54oEjJ7EdB65tGNHmH3pGZmVkgTP5RHvAs= +github.com/alecthomas/chroma/v2 v2.14.0 h1:R3+wzpnUArGcQz7fCETQBzO5n9IMNi13iIs46aU4V9E= +github.com/alecthomas/chroma/v2 v2.14.0/go.mod h1:QolEbTfmUHIMVpBqxeDnNBj2uoeI4EbYP4i6n68SG4I= +github.com/alecthomas/repr v0.0.0-20220113201626-b1b626ac65ae/go.mod h1:2kn6fqh/zIyPLmm3ugklbEi5hg5wS435eygvNfaDQL8= +github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc= +github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= @@ -91,8 +93,10 @@ github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1/go.mod h1:hyedUtir6IdtD/7lIxGeC github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0 h1:rpfIENRNNilwHwZeG5+P150SMrnNEcHYvcCuK6dPZSg= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0/go.mod h1:v57UDF4pDQJcEfFUCRop3lJL149eHGSe9Jvczhzjo/0= github.com/decred/dcrd/lru v1.0.0/go.mod h1:mxKOwFd7lFjN2GZYsiz/ecgqR6kkYAl+0pz0tEMk218= -github.com/dlclark/regexp2 v1.4.0 h1:F1rxgk7p4uKjwIQxBs9oAXe5CqrXlCduYEJvrF4u93E= github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc= +github.com/dlclark/regexp2 v1.7.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= +github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/fortytw2/leaktest v1.3.0 h1:u8491cBMTQ8ft8aeV+adlcytMZylmA5nnwwkRZjI8vw= @@ -128,21 +132,11 @@ github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/gorilla/csrf v1.7.0/go.mod h1:+a/4tCmqhG6/w4oafeAZ9pEa3/NZOWYVbD9fV0FwIQA= github.com/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY= github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c= -github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= -github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= -github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= -github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ= -github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= -github.com/gorilla/sessions v1.2.1 h1:DHd3rPN5lE3Ts3D8rKkQ8x/0kqfeNmBAaiSi+o7FsgI= -github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM= github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= -github.com/gotuna/gotuna v0.6.0 h1:N1lQKXEi/lwRp8u3sccTYLhzOffA4QasExz/1M5Riws= -github.com/gotuna/gotuna v0.6.0/go.mod h1:F/ecRt29ChB6Ycy1AFIBpBiMNK0j7Heq+gFbLWquhjc= github.com/grpc-ecosystem/grpc-gateway/v2 v2.22.0 h1:asbCHRVmodnJTuQ3qamDwqVOIjwqUPTYmYuemVOx+Ys= github.com/grpc-ecosystem/grpc-gateway/v2 v2.22.0/go.mod h1:ggCgvZ2r7uOoQjOyu2Y1NhHmEPPzzuhWgcza5M1Ji1I= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= @@ -219,6 +213,8 @@ github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU= github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA= github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= +github.com/sig-0/insertion-queue v0.0.0-20241004125609-6b3ca841346b h1:oV47z+jotrLVvhiLRNzACVe7/qZ8DcRlMlDucR/FARo= +github.com/sig-0/insertion-queue v0.0.0-20241004125609-6b3ca841346b/go.mod h1:JprPCeMgYyLKJoAy9nxpVScm7NwFSwpibdrUKm4kcw0= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= @@ -233,10 +229,13 @@ github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7/go.mod h1:q4W45 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/yuin/goldmark v1.3.7/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= -github.com/yuin/goldmark v1.5.4 h1:2uY/xC0roWy8IBEGLgB1ywIoEJFGmRrX21YQcvGZzjU= -github.com/yuin/goldmark v1.5.4/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/yuin/goldmark v1.4.15/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/yuin/goldmark v1.7.2 h1:NjGd7lO7zrUn/A7eKwn5PEOt4ONYGqpxSEeZuduvgxc= +github.com/yuin/goldmark v1.7.2/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= github.com/yuin/goldmark-emoji v1.0.2 h1:c/RgTShNgHTtc6xdz2KKI74jJr6rWi7FPgnP9GAsO5s= github.com/yuin/goldmark-emoji v1.0.2/go.mod h1:RhP/RWpexdp+KHs7ghKnifRoIs/Bq4nDS7tRbCkOwKY= +github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc h1:+IAOyRda+RLrxa1WC7umKOZRsGq4QrFFMYApOeHzQwQ= +github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc/go.mod h1:ovIvrum6DQJA4QsJSovrkC4saKHQVs7TvcaeO8AIl5I= github.com/zondax/hid v0.9.2 h1:WCJFnEDMiqGF64nlZz28E9qLVZ0KSJ7xpc5DLEyma2U= github.com/zondax/hid v0.9.2/go.mod h1:l5wttcP0jwtdLjqjMMWFVEE7d1zO0jvSPA9OPZxWpEM= github.com/zondax/ledger-go v0.14.3 h1:wEpJt2CEcBJ428md/5MgSLsXLBos98sBOyxNmCjfUCw= @@ -265,8 +264,8 @@ go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= -go.uber.org/zap/exp v0.2.0 h1:FtGenNNeCATRB3CmB/yEUnjEFeJWpB/pMcy7e2bKPYs= -go.uber.org/zap/exp v0.2.0/go.mod h1:t0gqAIdh1MfKv9EwN/dLwfZnJxe9ITAZN78HEWPFWDQ= +go.uber.org/zap/exp v0.3.0 h1:6JYzdifzYkGmTdRR59oYH+Ng7k49H9qVpWwNSsGJj3U= +go.uber.org/zap/exp v0.3.0/go.mod h1:5I384qq7XGxYyByIhHm6jg5CHkGY0nsTfbDLgDDlgJQ= golang.org/x/crypto v0.0.0-20170930174604-9419663f5a44/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= diff --git a/contribs/gnodev/pkg/dev/node.go b/contribs/gnodev/pkg/dev/node.go index 0502c03c86f..12a88490515 100644 --- a/contribs/gnodev/pkg/dev/node.go +++ b/contribs/gnodev/pkg/dev/node.go @@ -122,12 +122,9 @@ func NewDevNode(ctx context.Context, cfg *NodeConfig) (*Node, error) { initialState: cfg.InitialTxs, currentStateIndex: len(cfg.InitialTxs), } - - // generate genesis state - genesis := gnoland.GnoGenesisState{ - Balances: cfg.BalancesList, - Txs: append(pkgsTxs, cfg.InitialTxs...), - } + genesis := gnoland.DefaultGenState() + genesis.Balances = cfg.BalancesList + genesis.Txs = append(pkgsTxs, cfg.InitialTxs...) if err := devnode.rebuildNode(ctx, genesis); err != nil { return nil, fmt.Errorf("unable to initialize the node: %w", err) @@ -288,10 +285,9 @@ func (n *Node) Reset(ctx context.Context) error { // Append initialTxs txs := append(pkgsTxs, n.initialState...) - genesis := gnoland.GnoGenesisState{ - Balances: n.config.BalancesList, - Txs: txs, - } + genesis := gnoland.DefaultGenState() + genesis.Balances = n.config.BalancesList + genesis.Txs = txs // Reset the node with the new genesis state. err = n.rebuildNode(ctx, genesis) @@ -413,10 +409,10 @@ func (n *Node) rebuildNodeFromState(ctx context.Context) error { if err != nil { return fmt.Errorf("unable to load pkgs: %w", err) } - - return n.rebuildNode(ctx, gnoland.GnoGenesisState{ - Balances: n.config.BalancesList, Txs: txs, - }) + genesis := gnoland.DefaultGenState() + genesis.Balances = n.config.BalancesList + genesis.Txs = txs + return n.rebuildNode(ctx, genesis) } state, err := n.getBlockStoreState(ctx) @@ -431,10 +427,9 @@ func (n *Node) rebuildNodeFromState(ctx context.Context) error { } // Create genesis with loaded pkgs + previous state - genesis := gnoland.GnoGenesisState{ - Balances: n.config.BalancesList, - Txs: append(pkgsTxs, state...), - } + genesis := gnoland.DefaultGenState() + genesis.Balances = n.config.BalancesList + genesis.Txs = append(pkgsTxs, state...) // Reset the node with the new genesis state. err = n.rebuildNode(ctx, genesis) @@ -494,6 +489,8 @@ func (n *Node) rebuildNode(ctx context.Context, genesis gnoland.GnoGenesisState) // Speed up stdlib loading after first start (saves about 2-3 seconds on each reload). nodeConfig.CacheStdlibLoad = true nodeConfig.Genesis.ConsensusParams.Block.MaxGas = n.config.MaxGasPerBlock + // Genesis verification is always false with Gnodev + nodeConfig.SkipGenesisVerification = true // recoverFromError handles panics and converts them to errors. recoverFromError := func() { diff --git a/contribs/gnodev/pkg/dev/node_state.go b/contribs/gnodev/pkg/dev/node_state.go index 73362a5f1c8..3f996bc7716 100644 --- a/contribs/gnodev/pkg/dev/node_state.go +++ b/contribs/gnodev/pkg/dev/node_state.go @@ -92,10 +92,9 @@ func (n *Node) MoveBy(ctx context.Context, x int) error { newState := n.state[:newIndex] // Create genesis with loaded pkgs + previous state - genesis := gnoland.GnoGenesisState{ - Balances: n.config.BalancesList, - Txs: append(pkgsTxs, newState...), - } + genesis := gnoland.DefaultGenState() + genesis.Balances = n.config.BalancesList + genesis.Txs = append(pkgsTxs, newState...) // Reset the node with the new genesis state. if err = n.rebuildNode(ctx, genesis); err != nil { @@ -132,10 +131,11 @@ func (n *Node) ExportStateAsGenesis(ctx context.Context) (*bft.GenesisDoc, error // Get current blockstore state doc := *n.Node.GenesisDoc() // copy doc - doc.AppState = gnoland.GnoGenesisState{ - Balances: n.config.BalancesList, - Txs: state, - } + + genState := doc.AppState.(gnoland.GnoGenesisState) + genState.Balances = n.config.BalancesList + genState.Txs = state + doc.AppState = genState return &doc, nil } diff --git a/contribs/gnodev/pkg/dev/node_test.go b/contribs/gnodev/pkg/dev/node_test.go index 4a4acc232b9..38fab0a3360 100644 --- a/contribs/gnodev/pkg/dev/node_test.go +++ b/contribs/gnodev/pkg/dev/node_test.go @@ -438,7 +438,7 @@ func testingCallRealm(t *testing.T, node *Node, msgs ...vm.MsgCall) (*core_types txcfg := gnoclient.BaseTxCfg{ GasFee: ugnot.ValueString(1000000), // Gas fee - GasWanted: 2_000_000, // Gas wanted + GasWanted: 3_000_000, // Gas wanted } // Set Caller in the msgs diff --git a/contribs/gnofaucet/go.mod b/contribs/gnofaucet/go.mod index eab9fc90c50..3d1e5f54c54 100644 --- a/contribs/gnofaucet/go.mod +++ b/contribs/gnofaucet/go.mod @@ -31,6 +31,7 @@ require ( github.com/pmezard/go-difflib v1.0.0 // indirect github.com/rs/cors v1.11.1 // indirect github.com/rs/xid v1.6.0 // indirect + github.com/sig-0/insertion-queue v0.0.0-20241004125609-6b3ca841346b // indirect go.opentelemetry.io/otel v1.29.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.29.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.29.0 // indirect @@ -40,7 +41,7 @@ require ( go.opentelemetry.io/otel/trace v1.29.0 // indirect go.opentelemetry.io/proto/otlp v1.3.1 // indirect go.uber.org/multierr v1.11.0 // indirect - go.uber.org/zap/exp v0.2.0 // indirect + go.uber.org/zap/exp v0.3.0 // indirect golang.org/x/crypto v0.26.0 // indirect golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8 // indirect golang.org/x/mod v0.20.0 // indirect diff --git a/contribs/gnofaucet/go.sum b/contribs/gnofaucet/go.sum index aabe858e893..10e2c19b408 100644 --- a/contribs/gnofaucet/go.sum +++ b/contribs/gnofaucet/go.sum @@ -111,6 +111,8 @@ github.com/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA= github.com/rs/cors v1.11.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU= github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU= github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= +github.com/sig-0/insertion-queue v0.0.0-20241004125609-6b3ca841346b h1:oV47z+jotrLVvhiLRNzACVe7/qZ8DcRlMlDucR/FARo= +github.com/sig-0/insertion-queue v0.0.0-20241004125609-6b3ca841346b/go.mod h1:JprPCeMgYyLKJoAy9nxpVScm7NwFSwpibdrUKm4kcw0= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= @@ -146,8 +148,8 @@ go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= -go.uber.org/zap/exp v0.2.0 h1:FtGenNNeCATRB3CmB/yEUnjEFeJWpB/pMcy7e2bKPYs= -go.uber.org/zap/exp v0.2.0/go.mod h1:t0gqAIdh1MfKv9EwN/dLwfZnJxe9ITAZN78HEWPFWDQ= +go.uber.org/zap/exp v0.3.0 h1:6JYzdifzYkGmTdRR59oYH+Ng7k49H9qVpWwNSsGJj3U= +go.uber.org/zap/exp v0.3.0/go.mod h1:5I384qq7XGxYyByIhHm6jg5CHkGY0nsTfbDLgDDlgJQ= golang.org/x/crypto v0.0.0-20170930174604-9419663f5a44/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= diff --git a/contribs/gnogenesis/go.mod b/contribs/gnogenesis/go.mod index f1b316c2bee..3056af1d4cc 100644 --- a/contribs/gnogenesis/go.mod +++ b/contribs/gnogenesis/go.mod @@ -32,6 +32,7 @@ require ( github.com/pmezard/go-difflib v1.0.0 // indirect github.com/rs/cors v1.11.1 // indirect github.com/rs/xid v1.6.0 // indirect + github.com/sig-0/insertion-queue v0.0.0-20241004125609-6b3ca841346b // indirect github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7 // indirect github.com/zondax/hid v0.9.2 // indirect github.com/zondax/ledger-go v0.14.3 // indirect @@ -49,6 +50,7 @@ require ( golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8 // indirect golang.org/x/mod v0.20.0 // indirect golang.org/x/net v0.28.0 // indirect + golang.org/x/sync v0.8.0 // indirect golang.org/x/sys v0.24.0 // indirect golang.org/x/term v0.23.0 // indirect golang.org/x/text v0.17.0 // indirect diff --git a/contribs/gnogenesis/go.sum b/contribs/gnogenesis/go.sum index 7ba3aede534..7e4a683cad1 100644 --- a/contribs/gnogenesis/go.sum +++ b/contribs/gnogenesis/go.sum @@ -120,6 +120,8 @@ github.com/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA= github.com/rs/cors v1.11.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU= github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU= github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= +github.com/sig-0/insertion-queue v0.0.0-20241004125609-6b3ca841346b h1:oV47z+jotrLVvhiLRNzACVe7/qZ8DcRlMlDucR/FARo= +github.com/sig-0/insertion-queue v0.0.0-20241004125609-6b3ca841346b/go.mod h1:JprPCeMgYyLKJoAy9nxpVScm7NwFSwpibdrUKm4kcw0= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= diff --git a/contribs/gnogenesis/internal/txs/txs_add_packages.go b/contribs/gnogenesis/internal/txs/txs_add_packages.go index cf863c72116..53c0bb4b686 100644 --- a/contribs/gnogenesis/internal/txs/txs_add_packages.go +++ b/contribs/gnogenesis/internal/txs/txs_add_packages.go @@ -5,8 +5,9 @@ import ( "errors" "flag" "fmt" + "os" - "github.com/gnolang/gno/tm2/pkg/crypto" + "github.com/gnolang/gno/tm2/pkg/crypto/keys" "github.com/gnolang/gno/gno.land/pkg/gnoland" "github.com/gnolang/gno/gno.land/pkg/gnoland/ugnot" @@ -15,28 +16,44 @@ import ( "github.com/gnolang/gno/tm2/pkg/std" ) -var ( - errInvalidPackageDir = errors.New("invalid package directory") - errInvalidDeployerAddr = errors.New("invalid deployer address") +const ( + defaultAccount_Name = "test1" + defaultAccount_Seed = "source bonus chronic canvas draft south burst lottery vacant surface solve popular case indicate oppose farm nothing bullet exhibit title speed wink action roast" + defaultAccount_publicKey = "gpub1pgfj7ard9eg82cjtv4u4xetrwqer2dntxyfzxz3pq0skzdkmzu0r9h6gny6eg8c9dc303xrrudee6z4he4y7cs5rnjwmyf40yaj" ) +var errInvalidPackageDir = errors.New("invalid package directory") + // Keep in sync with gno.land/cmd/start.go -var ( - defaultCreator = crypto.MustAddressFromString("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5") // test1 - genesisDeployFee = std.NewFee(50000, std.MustParseCoin(ugnot.ValueString(1000000))) -) +var genesisDeployFee = std.NewFee(50000, std.MustParseCoin(ugnot.ValueString(1000000))) type addPkgCfg struct { - txsCfg *txsCfg - deployerAddress string + txsCfg *txsCfg + keyName string + gnoHome string // default GNOHOME env var, just here to ease testing with parallel tests + insecurePasswordStdin bool } func (c *addPkgCfg) RegisterFlags(fs *flag.FlagSet) { fs.StringVar( - &c.deployerAddress, - "deployer-address", - defaultCreator.String(), - "the address that will be used to deploy the package", + &c.keyName, + "key-name", + "", + "The package deployer key name or address contained on gnokey", + ) + + fs.StringVar( + &c.gnoHome, + "gno-home", + os.Getenv("GNOHOME"), + "the gno home directory", + ) + + fs.BoolVar( + &c.insecurePasswordStdin, + "insecure-password-stdin", + false, + "the gno home directory", ) } @@ -65,10 +82,15 @@ func execTxsAddPackages( io commands.IO, args []string, ) error { + var ( + keyname = defaultAccount_Name + keybase keys.Keybase + pass string + ) // Load the genesis - genesis, loadErr := types.GenesisDocFromFile(cfg.txsCfg.GenesisPath) - if loadErr != nil { - return fmt.Errorf("unable to load genesis, %w", loadErr) + genesis, err := types.GenesisDocFromFile(cfg.txsCfg.GenesisPath) + if err != nil { + return fmt.Errorf("unable to load genesis, %w", err) } // Make sure the package dir is set @@ -76,19 +98,30 @@ func execTxsAddPackages( return errInvalidPackageDir } - var ( - creator = defaultCreator - err error - ) - - // Check if the deployer address is set - if cfg.deployerAddress != defaultCreator.String() { - creator, err = crypto.AddressFromString(cfg.deployerAddress) + if cfg.keyName != "" { + keyname = cfg.keyName + keybase, err = keys.NewKeyBaseFromDir(cfg.gnoHome) + if err != nil { + return fmt.Errorf("unable to load keybase: %w", err) + } + pass, err = io.GetPassword("Enter password.", cfg.insecurePasswordStdin) + if err != nil { + return fmt.Errorf("cannot read password: %w", err) + } + } else { + keybase = keys.NewInMemory() + _, err := keybase.CreateAccount(defaultAccount_Name, defaultAccount_Seed, "", "", 0, 0) if err != nil { - return fmt.Errorf("%w, %w", errInvalidDeployerAddr, err) + return fmt.Errorf("unable to create account: %w", err) } } + info, err := keybase.GetByNameOrAddress(keyname) + if err != nil { + return fmt.Errorf("unable to find key in keybase: %w", err) + } + + creator := info.GetAddress() parsedTxs := make([]gnoland.TxWithMetadata, 0) for _, path := range args { // Generate transactions from the packages (recursively) @@ -97,6 +130,10 @@ func execTxsAddPackages( return fmt.Errorf("unable to load txs from directory, %w", err) } + if err := signTxs(txs, keybase, genesis.ChainID, keyname, pass); err != nil { + return fmt.Errorf("unable to sign txs, %w", err) + } + parsedTxs = append(parsedTxs, txs...) } @@ -117,3 +154,25 @@ func execTxsAddPackages( return nil } + +func signTxs(txs []gnoland.TxWithMetadata, keybase keys.Keybase, chainID, keyname string, password string) error { + for index, tx := range txs { + // Here accountNumber and sequenceNumber are set to 0 because they are considered as 0 on genesis transactions. + signBytes, err := tx.Tx.GetSignBytes(chainID, 0, 0) + if err != nil { + return fmt.Errorf("unable to load txs from directory, %w", err) + } + signature, publicKey, err := keybase.Sign(keyname, password, signBytes) + if err != nil { + return fmt.Errorf("unable sign tx %w", err) + } + txs[index].Tx.Signatures = []std.Signature{ + { + PubKey: publicKey, + Signature: signature, + }, + } + } + + return nil +} diff --git a/contribs/gnogenesis/internal/txs/txs_add_packages_test.go b/contribs/gnogenesis/internal/txs/txs_add_packages_test.go index c3405d6ff8d..38d930401e8 100644 --- a/contribs/gnogenesis/internal/txs/txs_add_packages_test.go +++ b/contribs/gnogenesis/internal/txs/txs_add_packages_test.go @@ -2,9 +2,11 @@ package txs import ( "context" + "encoding/hex" "fmt" "os" "path/filepath" + "strings" "testing" "github.com/gnolang/contribs/gnogenesis/internal/common" @@ -12,6 +14,8 @@ import ( vmm "github.com/gnolang/gno/gno.land/pkg/sdk/vm" "github.com/gnolang/gno/tm2/pkg/bft/types" "github.com/gnolang/gno/tm2/pkg/commands" + "github.com/gnolang/gno/tm2/pkg/crypto/keys" + "github.com/gnolang/gno/tm2/pkg/crypto/keys/client" "github.com/gnolang/gno/tm2/pkg/testutils" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -19,6 +23,7 @@ import ( func TestGenesis_Txs_Add_Packages(t *testing.T) { t.Parallel() + const addPkgExpectedSignature = "cfe5a15d8def04cbdaf9d08e2511db7928152b26419c4577cbfa282c83118852411f3de5d045ce934555572c21bda8042ce5c64b793a01748e49cf2cff7c2983" t.Run("invalid genesis file", func(t *testing.T) { t.Parallel() @@ -60,8 +65,10 @@ func TestGenesis_Txs_Add_Packages(t *testing.T) { assert.ErrorContains(t, cmdErr, errInvalidPackageDir.Error()) }) - t.Run("invalid deployer address", func(t *testing.T) { + t.Run("non existent key", func(t *testing.T) { t.Parallel() + keybaseDir := t.TempDir() + keyname := "beep-boop" tempGenesis, cleanup := testutils.NewTestFile(t) t.Cleanup(cleanup) @@ -69,24 +76,36 @@ func TestGenesis_Txs_Add_Packages(t *testing.T) { genesis := common.GetDefaultGenesis() require.NoError(t, genesis.SaveAs(tempGenesis.Name())) + io := commands.NewTestIO() + io.SetIn( + strings.NewReader( + fmt.Sprintf( + "%s\n", + "password", + ), + ), + ) // Create the command - cmd := NewTxsCmd(commands.NewTestIO()) + cmd := NewTxsCmd(io) args := []string{ "add", "packages", "--genesis-path", tempGenesis.Name(), t.TempDir(), // package dir - "--deployer-address", - "beep-boop", // invalid address + "--key-name", + keyname, // non-existent key name + "--gno-home", + keybaseDir, // temporaryDir for keybase + "--insecure-password-stdin", } // Run the command cmdErr := cmd.ParseAndRun(context.Background(), args) - assert.ErrorIs(t, cmdErr, errInvalidDeployerAddr) + assert.ErrorContains(t, cmdErr, "Key "+keyname+" not found") }) - t.Run("valid package", func(t *testing.T) { + t.Run("existent key wrong password", func(t *testing.T) { t.Parallel() tempGenesis, cleanup := testutils.NewTestFile(t) @@ -94,32 +113,189 @@ func TestGenesis_Txs_Add_Packages(t *testing.T) { genesis := common.GetDefaultGenesis() require.NoError(t, genesis.SaveAs(tempGenesis.Name())) + // Prepare the package + var ( + packagePath = "gno.land/p/demo/cuttlas" + dir = t.TempDir() + keybaseDir = t.TempDir() + keyname = "beep-boop" + password = "somepass" + ) + createValidFile(t, dir, packagePath) + // Create key + kb, err := keys.NewKeyBaseFromDir(keybaseDir) + require.NoError(t, err) + mnemonic, err := client.GenerateMnemonic(256) + require.NoError(t, err) + _, err = kb.CreateAccount(keyname, mnemonic, "", password+"wrong", 0, 0) + require.NoError(t, err) + + io := commands.NewTestIO() + io.SetIn( + strings.NewReader( + fmt.Sprintf( + "%s\n", + password, + ), + ), + ) + + // Create the command + cmd := NewTxsCmd(io) + args := []string{ + "add", + "packages", + "--genesis-path", + tempGenesis.Name(), + "--key-name", + keyname, // non-existent key name + "--gno-home", + keybaseDir, // temporaryDir for keybase + "--insecure-password-stdin", + dir, + } + + // Run the command + cmdErr := cmd.ParseAndRun(context.Background(), args) + assert.ErrorContains(t, cmdErr, "unable to sign txs") + }) + + t.Run("existent key correct password", func(t *testing.T) { + t.Parallel() + tempGenesis, cleanup := testutils.NewTestFile(t) + t.Cleanup(cleanup) + + genesis := common.GetDefaultGenesis() + require.NoError(t, genesis.SaveAs(tempGenesis.Name())) // Prepare the package var ( packagePath = "gno.land/p/demo/cuttlas" dir = t.TempDir() + keybaseDir = t.TempDir() + keyname = "beep-boop" + password = "somepass" ) + createValidFile(t, dir, packagePath) + // Create key + kb, err := keys.NewKeyBaseFromDir(keybaseDir) + require.NoError(t, err) + info, err := kb.CreateAccount(keyname, defaultAccount_Seed, "", password, 0, 0) + require.NoError(t, err) - createFile := func(path, data string) { - file, err := os.Create(path) - require.NoError(t, err) + io := commands.NewTestIO() + io.SetIn( + strings.NewReader( + fmt.Sprintf( + "%s\n", + password, + ), + ), + ) - _, err = file.WriteString(data) - require.NoError(t, err) + // Create the command + cmd := NewTxsCmd(io) + args := []string{ + "add", + "packages", + "--genesis-path", + tempGenesis.Name(), + "--key-name", + keyname, // non-existent key name + "--gno-home", + keybaseDir, // temporaryDir for keybase + "--insecure-password-stdin", + dir, } - // Create the gno.mod file - createFile( - filepath.Join(dir, "gno.mod"), - fmt.Sprintf("module %s\n", packagePath), + // Run the command + cmdErr := cmd.ParseAndRun(context.Background(), args) + require.NoError(t, cmdErr) + + // Validate the transactions were written down + updatedGenesis, err := types.GenesisDocFromFile(tempGenesis.Name()) + require.NoError(t, err) + require.NotNil(t, updatedGenesis.AppState) + + // Fetch the state + state := updatedGenesis.AppState.(gnoland.GnoGenesisState) + + require.Equal(t, 1, len(state.Txs)) + require.Equal(t, 1, len(state.Txs[0].Tx.Msgs)) + + msgAddPkg, ok := state.Txs[0].Tx.Msgs[0].(vmm.MsgAddPackage) + require.True(t, ok) + require.Equal(t, info.GetPubKey(), state.Txs[0].Tx.Signatures[0].PubKey) + require.Equal(t, addPkgExpectedSignature, hex.EncodeToString(state.Txs[0].Tx.Signatures[0].Signature)) + + assert.Equal(t, packagePath, msgAddPkg.Package.Path) + }) + + t.Run("ok default key", func(t *testing.T) { + t.Parallel() + + tempGenesis, cleanup := testutils.NewTestFile(t) + t.Cleanup(cleanup) + + genesis := common.GetDefaultGenesis() + require.NoError(t, genesis.SaveAs(tempGenesis.Name())) + // Prepare the package + var ( + packagePath = "gno.land/p/demo/cuttlas" + dir = t.TempDir() + keybaseDir = t.TempDir() ) + createValidFile(t, dir, packagePath) + + // Create the command + cmd := NewTxsCmd(commands.NewTestIO()) + args := []string{ + "add", + "packages", + "--genesis-path", + tempGenesis.Name(), + "--gno-home", + keybaseDir, // temporaryDir for keybase + dir, + } + + // Run the command + cmdErr := cmd.ParseAndRun(context.Background(), args) + require.NoError(t, cmdErr) + + // Validate the transactions were written down + updatedGenesis, err := types.GenesisDocFromFile(tempGenesis.Name()) + require.NoError(t, err) + require.NotNil(t, updatedGenesis.AppState) - // Create a simple main.gno - createFile( - filepath.Join(dir, "main.gno"), - "package cuttlas\n\nfunc Example() string {\nreturn \"Manos arriba!\"\n}", + // Fetch the state + state := updatedGenesis.AppState.(gnoland.GnoGenesisState) + + require.Equal(t, 1, len(state.Txs)) + require.Equal(t, 1, len(state.Txs[0].Tx.Msgs)) + + msgAddPkg, ok := state.Txs[0].Tx.Msgs[0].(vmm.MsgAddPackage) + require.True(t, ok) + require.Equal(t, defaultAccount_publicKey, state.Txs[0].Tx.Signatures[0].PubKey.String()) + require.Equal(t, addPkgExpectedSignature, hex.EncodeToString(state.Txs[0].Tx.Signatures[0].Signature)) + + assert.Equal(t, packagePath, msgAddPkg.Package.Path) + }) + + t.Run("valid package", func(t *testing.T) { + t.Parallel() + + tempGenesis, cleanup := testutils.NewTestFile(t) + t.Cleanup(cleanup) + + genesis := common.GetDefaultGenesis() + require.NoError(t, genesis.SaveAs(tempGenesis.Name())) + // Prepare the package + var ( + packagePath = "gno.land/p/demo/cuttlas" + dir = t.TempDir() ) + createValidFile(t, dir, packagePath) // Create the command cmd := NewTxsCmd(commands.NewTestIO()) @@ -148,7 +324,32 @@ func TestGenesis_Txs_Add_Packages(t *testing.T) { msgAddPkg, ok := state.Txs[0].Tx.Msgs[0].(vmm.MsgAddPackage) require.True(t, ok) + require.Equal(t, defaultAccount_publicKey, state.Txs[0].Tx.Signatures[0].PubKey.String()) + require.Equal(t, addPkgExpectedSignature, hex.EncodeToString(state.Txs[0].Tx.Signatures[0].Signature)) assert.Equal(t, packagePath, msgAddPkg.Package.Path) }) } + +func createValidFile(t *testing.T, dir string, packagePath string) { + t.Helper() + createFile := func(path, data string) { + file, err := os.Create(path) + require.NoError(t, err) + + _, err = file.WriteString(data) + require.NoError(t, err) + } + + // Create the gno.mod file + createFile( + filepath.Join(dir, "gno.mod"), + fmt.Sprintf("module %s\n", packagePath), + ) + + // Create a simple main.gno + createFile( + filepath.Join(dir, "main.gno"), + "package cuttlas\n\nfunc Example() string {\nreturn \"Manos arriba!\"\n}", + ) +} diff --git a/contribs/gnohealth/go.mod b/contribs/gnohealth/go.mod index 4f5862a0d2e..4a3f6392804 100644 --- a/contribs/gnohealth/go.mod +++ b/contribs/gnohealth/go.mod @@ -21,6 +21,7 @@ require ( github.com/peterbourgon/ff/v3 v3.4.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/rs/xid v1.6.0 // indirect + github.com/sig-0/insertion-queue v0.0.0-20241004125609-6b3ca841346b // indirect github.com/stretchr/testify v1.9.0 // indirect go.opentelemetry.io/otel v1.29.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.29.0 // indirect @@ -34,6 +35,7 @@ require ( golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8 // indirect golang.org/x/mod v0.20.0 // indirect golang.org/x/net v0.28.0 // indirect + golang.org/x/sync v0.8.0 // indirect golang.org/x/sys v0.24.0 // indirect golang.org/x/term v0.23.0 // indirect golang.org/x/text v0.17.0 // indirect diff --git a/contribs/gnohealth/go.sum b/contribs/gnohealth/go.sum index dd287d9ca84..02e8893406a 100644 --- a/contribs/gnohealth/go.sum +++ b/contribs/gnohealth/go.sum @@ -103,6 +103,8 @@ github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU= github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= +github.com/sig-0/insertion-queue v0.0.0-20241004125609-6b3ca841346b h1:oV47z+jotrLVvhiLRNzACVe7/qZ8DcRlMlDucR/FARo= +github.com/sig-0/insertion-queue v0.0.0-20241004125609-6b3ca841346b/go.mod h1:JprPCeMgYyLKJoAy9nxpVScm7NwFSwpibdrUKm4kcw0= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= @@ -149,6 +151,8 @@ golang.org/x/net v0.0.0-20200813134508-3edf25e44fcc/go.mod h1:/O7V0waA8r7cgGh81R golang.org/x/net v0.28.0 h1:a9JDOJc5GMUJ0+UDqmLT86WiEy7iWyIhz8gz8E4e5hE= golang.org/x/net v0.28.0/go.mod h1:yqtgsTWOOnlGLG9GFRrK3++bGOUEkNBoHZc8MEDWPNg= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= +golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= diff --git a/contribs/gnokeykc/go.mod b/contribs/gnokeykc/go.mod index 479daed22f6..157b5585828 100644 --- a/contribs/gnokeykc/go.mod +++ b/contribs/gnokeykc/go.mod @@ -35,6 +35,7 @@ require ( github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/rs/xid v1.6.0 // indirect + github.com/sig-0/insertion-queue v0.0.0-20241004125609-6b3ca841346b // indirect github.com/stretchr/testify v1.9.0 // indirect github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7 // indirect github.com/zondax/hid v0.9.2 // indirect @@ -52,6 +53,7 @@ require ( golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8 // indirect golang.org/x/mod v0.20.0 // indirect golang.org/x/net v0.28.0 // indirect + golang.org/x/sync v0.8.0 // indirect golang.org/x/sys v0.24.0 // indirect golang.org/x/term v0.23.0 // indirect golang.org/x/text v0.17.0 // indirect diff --git a/contribs/gnokeykc/go.sum b/contribs/gnokeykc/go.sum index cacf6788d45..7aac05b84a0 100644 --- a/contribs/gnokeykc/go.sum +++ b/contribs/gnokeykc/go.sum @@ -124,6 +124,8 @@ github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU= github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= +github.com/sig-0/insertion-queue v0.0.0-20241004125609-6b3ca841346b h1:oV47z+jotrLVvhiLRNzACVe7/qZ8DcRlMlDucR/FARo= +github.com/sig-0/insertion-queue v0.0.0-20241004125609-6b3ca841346b/go.mod h1:JprPCeMgYyLKJoAy9nxpVScm7NwFSwpibdrUKm4kcw0= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= @@ -180,6 +182,8 @@ golang.org/x/net v0.0.0-20200813134508-3edf25e44fcc/go.mod h1:/O7V0waA8r7cgGh81R golang.org/x/net v0.28.0 h1:a9JDOJc5GMUJ0+UDqmLT86WiEy7iWyIhz8gz8E4e5hE= golang.org/x/net v0.28.0/go.mod h1:yqtgsTWOOnlGLG9GFRrK3++bGOUEkNBoHZc8MEDWPNg= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= +golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= diff --git a/contribs/gnomd/go.mod b/contribs/gnomd/go.mod index 423e4414a79..57c07621324 100644 --- a/contribs/gnomd/go.mod +++ b/contribs/gnomd/go.mod @@ -21,7 +21,7 @@ require ( github.com/mattn/go-isatty v0.0.11 // indirect github.com/mattn/go-runewidth v0.0.12 // indirect github.com/rivo/uniseg v0.1.0 // indirect - golang.org/x/image v0.0.0-20191206065243-da761ea9ff43 // indirect + golang.org/x/image v0.18.0 // indirect golang.org/x/net v0.23.0 // indirect golang.org/x/sys v0.18.0 // indirect ) diff --git a/contribs/gnomd/go.sum b/contribs/gnomd/go.sum index 0ff70dd99fb..3d4666530b1 100644 --- a/contribs/gnomd/go.sum +++ b/contribs/gnomd/go.sum @@ -55,8 +55,9 @@ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ golang.org/dl v0.0.0-20190829154251-82a15e2f2ead/go.mod h1:IUMfjQLJQd4UTqG1Z90tenwKoCX93Gn3MAQJMOSBsDQ= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= -golang.org/x/image v0.0.0-20191206065243-da761ea9ff43 h1:gQ6GUSD102fPgli+Yb4cR/cGaHF7tNBt+GYoRCpGC7s= golang.org/x/image v0.0.0-20191206065243-da761ea9ff43/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/image v0.18.0 h1:jGzIakQa/ZXI1I0Fxvaa9W7yP25TqT6cHIHn+6CqvSQ= +golang.org/x/image v0.18.0/go.mod h1:4yyo5vMFQjVjUcVk4jEQcU9MGy/rulF5WvUILseCM2E= golang.org/x/net v0.0.0-20190813141303-74dc4d7220e7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.23.0 h1:7EYJ93RZ9vYSZAIb2x3lnuvqO5zneoD6IvWjuhfxjTs= golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= diff --git a/contribs/gnomigrate/go.mod b/contribs/gnomigrate/go.mod index cd31adc4f6f..49f40eb79af 100644 --- a/contribs/gnomigrate/go.mod +++ b/contribs/gnomigrate/go.mod @@ -29,6 +29,7 @@ require ( github.com/pmezard/go-difflib v1.0.0 // indirect github.com/rs/cors v1.11.1 // indirect github.com/rs/xid v1.6.0 // indirect + github.com/sig-0/insertion-queue v0.0.0-20241004125609-6b3ca841346b // indirect github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7 // indirect go.etcd.io/bbolt v1.3.11 // indirect go.opentelemetry.io/otel v1.29.0 // indirect @@ -44,6 +45,7 @@ require ( golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8 // indirect golang.org/x/mod v0.20.0 // indirect golang.org/x/net v0.28.0 // indirect + golang.org/x/sync v0.8.0 // indirect golang.org/x/sys v0.24.0 // indirect golang.org/x/term v0.23.0 // indirect golang.org/x/text v0.17.0 // indirect diff --git a/contribs/gnomigrate/go.sum b/contribs/gnomigrate/go.sum index 7ba3aede534..7e4a683cad1 100644 --- a/contribs/gnomigrate/go.sum +++ b/contribs/gnomigrate/go.sum @@ -120,6 +120,8 @@ github.com/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA= github.com/rs/cors v1.11.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU= github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU= github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= +github.com/sig-0/insertion-queue v0.0.0-20241004125609-6b3ca841346b h1:oV47z+jotrLVvhiLRNzACVe7/qZ8DcRlMlDucR/FARo= +github.com/sig-0/insertion-queue v0.0.0-20241004125609-6b3ca841346b/go.mod h1:JprPCeMgYyLKJoAy9nxpVScm7NwFSwpibdrUKm4kcw0= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= diff --git a/contribs/gnomigrate/internal/txs/txs.go b/contribs/gnomigrate/internal/txs/txs.go index 4c65ca6ef0b..231428d5064 100644 --- a/contribs/gnomigrate/internal/txs/txs.go +++ b/contribs/gnomigrate/internal/txs/txs.go @@ -184,7 +184,7 @@ func processFile(ctx context.Context, io commands.IO, source, destination string continue } - if _, err = outputFile.WriteString(fmt.Sprintf("%s\n", string(marshaledData))); err != nil { + if _, err = fmt.Fprintf(outputFile, "%s\n", marshaledData); err != nil { io.ErrPrintfln("unable to save to output file, %s", err) } } diff --git a/docs/reference/stdlibs/std/banker.md b/docs/reference/stdlibs/std/banker.md index 71eb3709ea2..b60b55ee93b 100644 --- a/docs/reference/stdlibs/std/banker.md +++ b/docs/reference/stdlibs/std/banker.md @@ -38,6 +38,10 @@ Returns `Banker` of the specified type. ```go banker := std.GetBanker(std.) ``` + +:::info `Banker` methods expect qualified denomination of the coins. Read more [here](./realm.md#coindenom). +::: + --- ## GetCoins diff --git a/docs/reference/stdlibs/std/chain.md b/docs/reference/stdlibs/std/chain.md index b1791e65608..6a1da6483fd 100644 --- a/docs/reference/stdlibs/std/chain.md +++ b/docs/reference/stdlibs/std/chain.md @@ -162,3 +162,19 @@ Derives the Realm address from its `pkgpath` parameter. ```go realmAddr := std.DerivePkgAddr("gno.land/r/demo/tamagotchi") // g1a3tu874agjlkrpzt9x90xv3uzncapcn959yte4 ``` +--- + +## CoinDenom +```go +func CoinDenom(pkgPath, coinName string) string +``` +Composes a qualified denomination string from the realm's `pkgPath` and the provided coin name, e.g. `/gno.land/r/demo/blog:blgcoin`. This method should be used to get fully qualified denominations of coins when interacting with the `Banker` module. It can also be used as a method of the `Realm` object, Read more[here](./realm.md#coindenom). + +#### Parameters +- `pkgPath` **string** - package path of the realm +- `coinName` **string** - The coin name used to build the qualified denomination. Must start with a lowercase letter, followed by 2–15 lowercase letters or digits. + +#### Usage +```go +denom := std.CoinDenom("gno.land/r/demo/blog", "blgcoin") // /gno.land/r/demo/blog:blgcoin +``` diff --git a/docs/reference/stdlibs/std/realm.md b/docs/reference/stdlibs/std/realm.md index 0c99b7134ea..f69cd874c75 100644 --- a/docs/reference/stdlibs/std/realm.md +++ b/docs/reference/stdlibs/std/realm.md @@ -14,6 +14,7 @@ type Realm struct { func (r Realm) Addr() Address {...} func (r Realm) PkgPath() string {...} func (r Realm) IsUser() bool {...} +func (r Realm) CoinDenom(coinName string) string {...} ``` ## Addr @@ -39,3 +40,15 @@ Checks if the realm it was called upon is a user realm. ```go if r.IsUser() {...} ``` +--- +## CoinDenom +Composes a qualified denomination string from the realm's `pkgPath` and the provided coin name, e.g. `/gno.land/r/demo/blog:blgcoin`. This method should be used to get fully qualified denominations of coins when interacting with the `Banker` module. + +#### Parameters +- `coinName` **string** - The coin name used to build the qualified denomination. Must start with a lowercase letter, followed by 2–15 lowercase letters or digits. + +#### Usage +```go +// in "gno.land/r/gnoland/blog" +denom := r.CoinDenom("blgcoin") // /gno.land/r/gnoland/blog:blgcoin +``` diff --git a/examples/gno.land/p/demo/avl/list/gno.mod b/examples/gno.land/p/demo/avl/list/gno.mod new file mode 100644 index 00000000000..c05923b7708 --- /dev/null +++ b/examples/gno.land/p/demo/avl/list/gno.mod @@ -0,0 +1 @@ +module gno.land/p/demo/avl/list diff --git a/examples/gno.land/p/demo/avl/list/list.gno b/examples/gno.land/p/demo/avl/list/list.gno new file mode 100644 index 00000000000..594f5fa2a1f --- /dev/null +++ b/examples/gno.land/p/demo/avl/list/list.gno @@ -0,0 +1,314 @@ +// Package list implements a dynamic list data structure backed by an AVL tree. +// It provides O(log n) operations for most list operations while maintaining +// order stability. +// +// The list supports various operations including append, get, set, delete, +// range queries, and iteration. It can store values of any type. +// +// Example usage: +// +// // Create a new list and add elements +// var l list.List +// l.Append(1, 2, 3) +// +// // Get and set elements +// value := l.Get(1) // returns 2 +// l.Set(1, 42) // updates index 1 to 42 +// +// // Delete elements +// l.Delete(0) // removes first element +// +// // Iterate over elements +// l.ForEach(func(index int, value interface{}) bool { +// ufmt.Printf("index %d: %v\n", index, value) +// return false // continue iteration +// }) +// // Output: +// // index 0: 42 +// // index 1: 3 +// +// // Create a list of specific size +// l = list.Make(3, "default") // creates [default, default, default] +// +// // Create a list using a variable declaration +// var l2 list.List +// l2.Append(4, 5, 6) +// println(l2.Len()) // Output: 3 +package list + +import ( + "gno.land/p/demo/avl" + "gno.land/p/demo/seqid" +) + +// IList defines the interface for list operations +type IList interface { + Len() int + Append(values ...interface{}) + Get(index int) interface{} + Set(index int, value interface{}) bool + Delete(index int) (interface{}, bool) + Slice(startIndex, endIndex int) []interface{} + ForEach(fn func(index int, value interface{}) bool) + Clone() *List + DeleteRange(startIndex, endIndex int) int +} + +// Verify List implements IList interface +var _ IList = (*List)(nil) + +// List represents an ordered sequence of items backed by an AVL tree +type List struct { + tree avl.Tree + idGen seqid.ID +} + +// Len returns the number of elements in the list. +// +// Example: +// +// l := list.New() +// l.Append(1, 2, 3) +// println(l.Len()) // Output: 3 +func (l *List) Len() int { + return l.tree.Size() +} + +// Append adds one or more values to the end of the list. +// +// Example: +// +// l := list.New() +// l.Append(1) // adds single value +// l.Append(2, 3, 4) // adds multiple values +// println(l.Len()) // Output: 4 +func (l *List) Append(values ...interface{}) { + for _, v := range values { + l.tree.Set(l.idGen.Next().String(), v) + } +} + +// Get returns the value at the specified index. +// Returns nil if index is out of bounds. +// +// Example: +// +// l := list.New() +// l.Append(1, 2, 3) +// println(l.Get(1)) // Output: 2 +// println(l.Get(-1)) // Output: nil +// println(l.Get(999)) // Output: nil +func (l *List) Get(index int) interface{} { + if index < 0 || index >= l.tree.Size() { + return nil + } + _, value := l.tree.GetByIndex(index) + return value +} + +// Set updates or appends a value at the specified index. +// Returns true if the operation was successful, false otherwise. +// For empty lists, only index 0 is valid (append case). +// +// Example: +// +// l := list.New() +// l.Append(1, 2, 3) +// +// l.Set(1, 42) // updates existing index +// println(l.Get(1)) // Output: 42 +// +// l.Set(3, 4) // appends at end +// println(l.Get(3)) // Output: 4 +// +// l.Set(-1, 5) // invalid index +// println(l.Len()) // Output: 4 (list unchanged) +func (l *List) Set(index int, value interface{}) bool { + size := l.tree.Size() + + // Handle empty list case - only allow index 0 + if size == 0 { + if index == 0 { + l.Append(value) + return true + } + return false + } + + if index < 0 || index > size { + return false + } + + // If setting at the end (append case) + if index == size { + l.Append(value) + return true + } + + // Get the key at the specified index + key, _ := l.tree.GetByIndex(index) + if key == "" { + return false + } + + // Update the value at the existing key + l.tree.Set(key, value) + return true +} + +// Delete removes the element at the specified index. +// Returns the deleted value and true if successful, nil and false otherwise. +// +// Example: +// +// l := list.New() +// l.Append(1, 2, 3) +// +// val, ok := l.Delete(1) +// println(val, ok) // Output: 2 true +// println(l.Len()) // Output: 2 +// +// val, ok = l.Delete(-1) +// println(val, ok) // Output: nil false +func (l *List) Delete(index int) (interface{}, bool) { + size := l.tree.Size() + // Always return nil, false for empty list + if size == 0 { + return nil, false + } + + if index < 0 || index >= size { + return nil, false + } + + key, value := l.tree.GetByIndex(index) + if key == "" { + return nil, false + } + + l.tree.Remove(key) + return value, true +} + +// Slice returns a slice of values from startIndex (inclusive) to endIndex (exclusive). +// Returns nil if the range is invalid. +// +// Example: +// +// l := list.New() +// l.Append(1, 2, 3, 4, 5) +// +// println(l.Slice(1, 4)) // Output: [2 3 4] +// println(l.Slice(-1, 2)) // Output: [1 2] +// println(l.Slice(3, 999)) // Output: [4 5] +// println(l.Slice(3, 2)) // Output: nil +func (l *List) Slice(startIndex, endIndex int) []interface{} { + size := l.tree.Size() + + // Normalize bounds + if startIndex < 0 { + startIndex = 0 + } + if endIndex > size { + endIndex = size + } + if startIndex >= endIndex { + return nil + } + + count := endIndex - startIndex + result := make([]interface{}, count) + + i := 0 + l.tree.IterateByOffset(startIndex, count, func(_ string, value interface{}) bool { + result[i] = value + i++ + return false + }) + return result +} + +// ForEach iterates through all elements in the list. +func (l *List) ForEach(fn func(index int, value interface{}) bool) { + if l.tree.Size() == 0 { + return + } + + index := 0 + l.tree.IterateByOffset(0, l.tree.Size(), func(_ string, value interface{}) bool { + result := fn(index, value) + index++ + return result + }) +} + +// Clone creates a shallow copy of the list. +// +// Example: +// +// l := list.New() +// l.Append(1, 2, 3) +// +// clone := l.Clone() +// clone.Set(0, 42) +// +// println(l.Get(0)) // Output: 1 +// println(clone.Get(0)) // Output: 42 +func (l *List) Clone() *List { + newList := &List{ + tree: avl.Tree{}, + idGen: l.idGen, + } + + size := l.tree.Size() + if size == 0 { + return newList + } + + l.tree.IterateByOffset(0, size, func(_ string, value interface{}) bool { + newList.Append(value) + return false + }) + + return newList +} + +// DeleteRange removes elements from startIndex (inclusive) to endIndex (exclusive). +// Returns the number of elements deleted. +// +// Example: +// +// l := list.New() +// l.Append(1, 2, 3, 4, 5) +// +// deleted := l.DeleteRange(1, 4) +// println(deleted) // Output: 3 +// println(l.Range(0, l.Len())) // Output: [1 5] +func (l *List) DeleteRange(startIndex, endIndex int) int { + size := l.tree.Size() + + // Normalize bounds + if startIndex < 0 { + startIndex = 0 + } + if endIndex > size { + endIndex = size + } + if startIndex >= endIndex { + return 0 + } + + // Collect keys to delete + keysToDelete := make([]string, 0, endIndex-startIndex) + l.tree.IterateByOffset(startIndex, endIndex-startIndex, func(key string, _ interface{}) bool { + keysToDelete = append(keysToDelete, key) + return false + }) + + // Delete collected keys + for _, key := range keysToDelete { + l.tree.Remove(key) + } + + return len(keysToDelete) +} diff --git a/examples/gno.land/p/demo/avl/list/list_test.gno b/examples/gno.land/p/demo/avl/list/list_test.gno new file mode 100644 index 00000000000..0293692f660 --- /dev/null +++ b/examples/gno.land/p/demo/avl/list/list_test.gno @@ -0,0 +1,473 @@ +package list + +import ( + "testing" + + "gno.land/p/demo/ufmt" +) + +func TestList_Basic(t *testing.T) { + var l List + + // Test empty list + if l.Len() != 0 { + t.Errorf("new list should be empty, got len %d", l.Len()) + } + + // Test append and length + l.Append(1, 2, 3) + if l.Len() != 3 { + t.Errorf("expected len 3, got %d", l.Len()) + } + + // Test get + if v := l.Get(0); v != 1 { + t.Errorf("expected 1 at index 0, got %v", v) + } + if v := l.Get(1); v != 2 { + t.Errorf("expected 2 at index 1, got %v", v) + } + if v := l.Get(2); v != 3 { + t.Errorf("expected 3 at index 2, got %v", v) + } + + // Test out of bounds + if v := l.Get(-1); v != nil { + t.Errorf("expected nil for negative index, got %v", v) + } + if v := l.Get(3); v != nil { + t.Errorf("expected nil for out of bounds index, got %v", v) + } +} + +func TestList_Set(t *testing.T) { + var l List + l.Append(1, 2, 3) + + // Test valid set within bounds + if ok := l.Set(1, 42); !ok { + t.Error("Set should return true for valid index") + } + if v := l.Get(1); v != 42 { + t.Errorf("expected 42 after Set, got %v", v) + } + + // Test set at size (append) + if ok := l.Set(3, 4); !ok { + t.Error("Set should return true when appending at size") + } + if v := l.Get(3); v != 4 { + t.Errorf("expected 4 after Set at size, got %v", v) + } + + // Test invalid sets + if ok := l.Set(-1, 10); ok { + t.Error("Set should return false for negative index") + } + if ok := l.Set(5, 10); ok { + t.Error("Set should return false for index > size") + } + + // Verify list state hasn't changed after invalid operations + expected := []interface{}{1, 42, 3, 4} + for i, want := range expected { + if got := l.Get(i); got != want { + t.Errorf("index %d = %v; want %v", i, got, want) + } + } +} + +func TestList_Delete(t *testing.T) { + var l List + l.Append(1, 2, 3) + + // Test valid delete + if v, ok := l.Delete(1); !ok || v != 2 { + t.Errorf("Delete(1) = %v, %v; want 2, true", v, ok) + } + if l.Len() != 2 { + t.Errorf("expected len 2 after delete, got %d", l.Len()) + } + if v := l.Get(1); v != 3 { + t.Errorf("expected 3 at index 1 after delete, got %v", v) + } + + // Test invalid delete + if v, ok := l.Delete(-1); ok || v != nil { + t.Errorf("Delete(-1) = %v, %v; want nil, false", v, ok) + } + if v, ok := l.Delete(2); ok || v != nil { + t.Errorf("Delete(2) = %v, %v; want nil, false", v, ok) + } +} + +func TestList_Slice(t *testing.T) { + var l List + l.Append(1, 2, 3, 4, 5) + + // Test valid ranges + values := l.Slice(1, 4) + expected := []interface{}{2, 3, 4} + if !sliceEqual(values, expected) { + t.Errorf("Slice(1,4) = %v; want %v", values, expected) + } + + // Test edge cases + if values := l.Slice(-1, 2); !sliceEqual(values, []interface{}{1, 2}) { + t.Errorf("Slice(-1,2) = %v; want [1 2]", values) + } + if values := l.Slice(3, 10); !sliceEqual(values, []interface{}{4, 5}) { + t.Errorf("Slice(3,10) = %v; want [4 5]", values) + } + if values := l.Slice(3, 2); values != nil { + t.Errorf("Slice(3,2) = %v; want nil", values) + } +} + +func TestList_ForEach(t *testing.T) { + var l List + l.Append(1, 2, 3) + + sum := 0 + l.ForEach(func(index int, value interface{}) bool { + sum += value.(int) + return false + }) + + if sum != 6 { + t.Errorf("ForEach sum = %d; want 6", sum) + } + + // Test early termination + count := 0 + l.ForEach(func(index int, value interface{}) bool { + count++ + return true // stop after first item + }) + + if count != 1 { + t.Errorf("ForEach early termination count = %d; want 1", count) + } +} + +func TestList_Clone(t *testing.T) { + var l List + l.Append(1, 2, 3) + + clone := l.Clone() + + // Test same length + if clone.Len() != l.Len() { + t.Errorf("clone.Len() = %d; want %d", clone.Len(), l.Len()) + } + + // Test same values + for i := 0; i < l.Len(); i++ { + if clone.Get(i) != l.Get(i) { + t.Errorf("clone.Get(%d) = %v; want %v", i, clone.Get(i), l.Get(i)) + } + } + + // Test independence + l.Set(0, 42) + if clone.Get(0) == l.Get(0) { + t.Error("clone should be independent of original") + } +} + +func TestList_DeleteRange(t *testing.T) { + var l List + l.Append(1, 2, 3, 4, 5) + + // Test valid range delete + deleted := l.DeleteRange(1, 4) + if deleted != 3 { + t.Errorf("DeleteRange(1,4) deleted %d elements; want 3", deleted) + } + if l.Len() != 2 { + t.Errorf("after DeleteRange(1,4) len = %d; want 2", l.Len()) + } + expected := []interface{}{1, 5} + for i, want := range expected { + if got := l.Get(i); got != want { + t.Errorf("after DeleteRange(1,4) index %d = %v; want %v", i, got, want) + } + } + + // Test edge cases + l = List{} + l.Append(1, 2, 3) + + // Delete with negative start + if deleted := l.DeleteRange(-1, 2); deleted != 2 { + t.Errorf("DeleteRange(-1,2) deleted %d elements; want 2", deleted) + } + + // Delete with end > length + l = List{} + l.Append(1, 2, 3) + if deleted := l.DeleteRange(1, 5); deleted != 2 { + t.Errorf("DeleteRange(1,5) deleted %d elements; want 2", deleted) + } + + // Delete invalid range + if deleted := l.DeleteRange(2, 1); deleted != 0 { + t.Errorf("DeleteRange(2,1) deleted %d elements; want 0", deleted) + } + + // Delete empty range + if deleted := l.DeleteRange(1, 1); deleted != 0 { + t.Errorf("DeleteRange(1,1) deleted %d elements; want 0", deleted) + } +} + +func TestList_EmptyOperations(t *testing.T) { + var l List + + // Operations on empty list + if v := l.Get(0); v != nil { + t.Errorf("Get(0) on empty list = %v; want nil", v) + } + + // Set should work at index 0 for empty list (append case) + if ok := l.Set(0, 1); !ok { + t.Error("Set(0,1) on empty list = false; want true") + } + if v := l.Get(0); v != 1 { + t.Errorf("Get(0) after Set = %v; want 1", v) + } + + l = List{} // Reset to empty list + if v, ok := l.Delete(0); ok || v != nil { + t.Errorf("Delete(0) on empty list = %v, %v; want nil, false", v, ok) + } + if values := l.Slice(0, 1); values != nil { + t.Errorf("Range(0,1) on empty list = %v; want nil", values) + } +} + +func TestList_DifferentTypes(t *testing.T) { + var l List + + // Test with different types + l.Append(42, "hello", true, 3.14) + + if v := l.Get(0).(int); v != 42 { + t.Errorf("Get(0) = %v; want 42", v) + } + if v := l.Get(1).(string); v != "hello" { + t.Errorf("Get(1) = %v; want 'hello'", v) + } + if v := l.Get(2).(bool); !v { + t.Errorf("Get(2) = %v; want true", v) + } + if v := l.Get(3).(float64); v != 3.14 { + t.Errorf("Get(3) = %v; want 3.14", v) + } +} + +func TestList_LargeOperations(t *testing.T) { + var l List + + // Test with larger number of elements + n := 1000 + for i := 0; i < n; i++ { + l.Append(i) + } + + if l.Len() != n { + t.Errorf("Len() = %d; want %d", l.Len(), n) + } + + // Test range on large list + values := l.Slice(n-3, n) + expected := []interface{}{n - 3, n - 2, n - 1} + if !sliceEqual(values, expected) { + t.Errorf("Range(%d,%d) = %v; want %v", n-3, n, values, expected) + } + + // Test large range deletion + deleted := l.DeleteRange(100, 900) + if deleted != 800 { + t.Errorf("DeleteRange(100,900) = %d; want 800", deleted) + } + if l.Len() != 200 { + t.Errorf("Len() after large delete = %d; want 200", l.Len()) + } +} + +func TestList_ChainedOperations(t *testing.T) { + var l List + + // Test sequence of operations + l.Append(1, 2, 3) + l.Delete(1) + l.Append(4) + l.Set(1, 5) + + expected := []interface{}{1, 5, 4} + for i, want := range expected { + if got := l.Get(i); got != want { + t.Errorf("index %d = %v; want %v", i, got, want) + } + } +} + +func TestList_RangeEdgeCases(t *testing.T) { + var l List + l.Append(1, 2, 3, 4, 5) + + // Test various edge cases for Range + cases := []struct { + start, end int + want []interface{} + }{ + {-10, 2, []interface{}{1, 2}}, + {3, 10, []interface{}{4, 5}}, + {0, 0, nil}, + {5, 5, nil}, + {4, 3, nil}, + {-1, -1, nil}, + } + + for _, tc := range cases { + got := l.Slice(tc.start, tc.end) + if !sliceEqual(got, tc.want) { + t.Errorf("Slice(%d,%d) = %v; want %v", tc.start, tc.end, got, tc.want) + } + } +} + +func TestList_IndexConsistency(t *testing.T) { + var l List + + // Initial additions + l.Append(1, 2, 3, 4, 5) // [1,2,3,4,5] + + // Delete from middle + l.Delete(2) // [1,2,4,5] + + // Add more elements + l.Append(6, 7) // [1,2,4,5,6,7] + + // Delete range from middle + l.DeleteRange(1, 4) // [1,6,7] + + // Add more elements + l.Append(8, 9, 10) // [1,6,7,8,9,10] + + // Verify sequence is continuous + expected := []interface{}{1, 6, 7, 8, 9, 10} + for i, want := range expected { + if got := l.Get(i); got != want { + t.Errorf("index %d = %v; want %v", i, got, want) + } + } + + // Verify no extra elements exist + if l.Len() != len(expected) { + t.Errorf("length = %d; want %d", l.Len(), len(expected)) + } + + // Verify all indices are accessible + allValues := l.Slice(0, l.Len()) + if !sliceEqual(allValues, expected) { + t.Errorf("Slice(0, Len()) = %v; want %v", allValues, expected) + } + + // Verify no gaps in iteration + var iteratedValues []interface{} + var indices []int + l.ForEach(func(index int, value interface{}) bool { + iteratedValues = append(iteratedValues, value) + indices = append(indices, index) + return false + }) + + // Check values from iteration + if !sliceEqual(iteratedValues, expected) { + t.Errorf("ForEach values = %v; want %v", iteratedValues, expected) + } + + // Check indices are sequential + for i, idx := range indices { + if idx != i { + t.Errorf("ForEach index %d = %d; want %d", i, idx, i) + } + } +} + +func TestList_RecursiveSafety(t *testing.T) { + // Create a new list + l := &List{} + + // Add some initial values + l.Append("id1") + l.Append("id2") + l.Append("id3") + + // Test deep list traversal + found := false + l.ForEach(func(i int, v interface{}) bool { + if str, ok := v.(string); ok { + if str == "id2" { + found = true + return true // stop iteration + } + } + return false // continue iteration + }) + + if !found { + t.Error("Failed to find expected value in list") + } + + short := testing.Short() + + // Test recursive safety by performing multiple operations + for i := 0; i < 1000; i++ { + // Add new value + l.Append(ufmt.Sprintf("id%d", i+4)) + + if !short { + // Search for a value + var lastFound bool + l.ForEach(func(j int, v interface{}) bool { + if str, ok := v.(string); ok { + if str == ufmt.Sprintf("id%d", i+3) { + lastFound = true + return true + } + } + return false + }) + + if !lastFound { + t.Errorf("Failed to find value id%d after insertion", i+3) + } + } + } + + // Verify final length + expectedLen := 1003 // 3 initial + 1000 added + if l.Len() != expectedLen { + t.Errorf("Expected length %d, got %d", expectedLen, l.Len()) + } + + if short { + t.Skip("skipping extended recursive safety test in short mode") + } +} + +// Helper function to compare slices +func sliceEqual(a, b []interface{}) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} diff --git a/examples/gno.land/p/demo/avl/node.gno b/examples/gno.land/p/demo/avl/node.gno index 7308e163768..7d4ddffff02 100644 --- a/examples/gno.land/p/demo/avl/node.gno +++ b/examples/gno.land/p/demo/avl/node.gno @@ -384,7 +384,7 @@ func (node *Node) TraverseInRange(start, end string, ascending bool, leavesOnly // TraverseByOffset traverses all nodes, including inner nodes. // A limit of math.MaxInt means no limit. -func (node *Node) TraverseByOffset(offset, limit int, descending bool, leavesOnly bool, cb func(*Node) bool) bool { +func (node *Node) TraverseByOffset(offset, limit int, ascending bool, leavesOnly bool, cb func(*Node) bool) bool { if node == nil { return false } @@ -401,21 +401,21 @@ func (node *Node) TraverseByOffset(offset, limit int, descending bool, leavesOnl } // go to the actual recursive function. - return node.traverseByOffset(offset, limit, descending, leavesOnly, cb) + return node.traverseByOffset(offset, limit, ascending, leavesOnly, cb) } // TraverseByOffset traverses the subtree rooted at the node by offset and limit, // in either ascending or descending order, and applies the callback function to each traversed node. // If leavesOnly is true, only leaf nodes are visited. -func (node *Node) traverseByOffset(offset, limit int, descending bool, leavesOnly bool, cb func(*Node) bool) bool { +func (node *Node) traverseByOffset(offset, limit int, ascending bool, leavesOnly bool, cb func(*Node) bool) bool { // caller guarantees: offset < node.size; limit > 0. if !leavesOnly { if cb(node) { - return true + return true // Stop traversal if callback returns true } } first, second := node.getLeftNode(), node.getRightNode() - if descending { + if !ascending { first, second = second, first } if first.IsLeaf() { @@ -423,10 +423,12 @@ func (node *Node) traverseByOffset(offset, limit int, descending bool, leavesOnl if offset > 0 { offset-- } else { - cb(first) + if cb(first) { + return true // Stop traversal if callback returns true + } limit-- if limit <= 0 { - return false + return true // Stop traversal when limit is reached } } } else { @@ -437,7 +439,7 @@ func (node *Node) traverseByOffset(offset, limit int, descending bool, leavesOnl if offset >= first.size { offset -= first.size // 1 } else { - if first.traverseByOffset(offset, limit, descending, leavesOnly, cb) { + if first.traverseByOffset(offset, limit, ascending, leavesOnly, cb) { return true } // number of leaves which could actually be called from inside @@ -460,7 +462,7 @@ func (node *Node) traverseByOffset(offset, limit int, descending bool, leavesOnl } // => if it is not a leaf, it will still be enough to recursively call this // function with the updated offset and limit - return second.traverseByOffset(offset, limit, descending, leavesOnly, cb) + return second.traverseByOffset(offset, limit, ascending, leavesOnly, cb) } // Only used in testing... diff --git a/examples/gno.land/p/demo/avl/node_test.gno b/examples/gno.land/p/demo/avl/node_test.gno index f24217625ea..3682cbc7c80 100644 --- a/examples/gno.land/p/demo/avl/node_test.gno +++ b/examples/gno.land/p/demo/avl/node_test.gno @@ -17,36 +17,34 @@ Book Browser` tt := []struct { name string - desc bool + asc bool }{ - {"ascending", false}, - {"descending", true}, + {"ascending", true}, + {"descending", false}, } for _, tt := range tt { t.Run(tt.name, func(t *testing.T) { + // use sl to insert the values, and reversed to match the values + // we do this to ensure that the order of TraverseByOffset is independent + // from the insertion order sl := strings.Split(testStrings, "\n") - - // sort a first time in the order opposite to how we'll be traversing - // the tree, to ensure that we are not just iterating through with - // insertion order. sort.Strings(sl) - if !tt.desc { - reverseSlice(sl) + reversed := append([]string{}, sl...) + reverseSlice(reversed) + + if !tt.asc { + sl, reversed = reversed, sl } - r := NewNode(sl[0], nil) - for _, v := range sl[1:] { + r := NewNode(reversed[0], nil) + for _, v := range reversed[1:] { r, _ = r.Set(v, nil) } - // then sort sl in the order we'll be traversing it, so that we can - // compare the result with sl. - reverseSlice(sl) - var result []string for i := 0; i < len(sl); i++ { - r.TraverseByOffset(i, 1, tt.desc, true, func(n *Node) bool { + r.TraverseByOffset(i, 1, tt.asc, true, func(n *Node) bool { result = append(result, n.Key()) return false }) @@ -66,7 +64,7 @@ Browser` exp := sl[i:max] actual := []string{} - r.TraverseByOffset(i, l, tt.desc, true, func(tr *Node) bool { + r.TraverseByOffset(i, l, tt.asc, true, func(tr *Node) bool { actual = append(actual, tr.Key()) return false }) @@ -422,6 +420,30 @@ func TestTraverse(t *testing.T) { t.Errorf("want %v got %v", expected, result) } }) + + t.Run("early termination", func(t *testing.T) { + if len(tt.input) == 0 { + return // Skip for empty tree + } + + var result []string + var count int + tree.Iterate("", "", func(n *Node) bool { + count++ + result = append(result, n.Key()) + return true // Stop after first item + }) + + if count != 1 { + t.Errorf("Expected callback to be called exactly once, got %d calls", count) + } + if len(result) != 1 { + t.Errorf("Expected exactly one result, got %d items", len(result)) + } + if len(result) > 0 && result[0] != tt.expected[0] { + t.Errorf("Expected first item to be %v, got %v", tt.expected[0], result[0]) + } + }) }) } } @@ -435,7 +457,7 @@ func TestRotateWhenHeightDiffers(t *testing.T) { { "right rotation when left subtree is higher", []string{"E", "C", "A", "B", "D"}, - []string{"A", "B", "C", "E", "D"}, + []string{"A", "B", "C", "D", "E"}, }, { "left rotation when right subtree is higher", @@ -445,12 +467,12 @@ func TestRotateWhenHeightDiffers(t *testing.T) { { "left-right rotation", []string{"E", "A", "C", "B", "D"}, - []string{"A", "B", "C", "E", "D"}, + []string{"A", "B", "C", "D", "E"}, }, { "right-left rotation", []string{"A", "E", "C", "B", "D"}, - []string{"A", "B", "C", "E", "D"}, + []string{"A", "B", "C", "D", "E"}, }, } @@ -533,7 +555,7 @@ func slicesEqual(w1, w2 []string) bool { return false } for i := 0; i < len(w1); i++ { - if w1[0] != w2[0] { + if w1[i] != w2[i] { return false } } diff --git a/examples/gno.land/p/demo/avl/pager/pager.gno b/examples/gno.land/p/demo/avl/pager/pager.gno index cccdc0df645..f5f909a473d 100644 --- a/examples/gno.land/p/demo/avl/pager/pager.gno +++ b/examples/gno.land/p/demo/avl/pager/pager.gno @@ -11,7 +11,7 @@ import ( // Pager is a struct that holds the AVL tree and pagination parameters. type Pager struct { - Tree *avl.Tree + Tree avl.ITree PageQueryParam string SizeQueryParam string DefaultPageSize int @@ -37,7 +37,7 @@ type Item struct { } // NewPager creates a new Pager with default values. -func NewPager(tree *avl.Tree, defaultPageSize int, reversed bool) *Pager { +func NewPager(tree avl.ITree, defaultPageSize int, reversed bool) *Pager { return &Pager{ Tree: tree, PageQueryParam: "page", @@ -90,12 +90,12 @@ func (p *Pager) GetPageWithSize(pageNumber, pageSize int) *Page { items := []Item{} if p.Reversed { - p.Tree.IterateByOffset(startIndex, endIndex-startIndex, func(key string, value interface{}) bool { + p.Tree.ReverseIterateByOffset(startIndex, endIndex-startIndex, func(key string, value interface{}) bool { items = append(items, Item{Key: key, Value: value}) return false }) } else { - p.Tree.ReverseIterateByOffset(startIndex, endIndex-startIndex, func(key string, value interface{}) bool { + p.Tree.IterateByOffset(startIndex, endIndex-startIndex, func(key string, value interface{}) bool { items = append(items, Item{Key: key, Value: value}) return false }) diff --git a/examples/gno.land/p/demo/avl/rolist/gno.mod b/examples/gno.land/p/demo/avl/rolist/gno.mod new file mode 100644 index 00000000000..682513c2cc3 --- /dev/null +++ b/examples/gno.land/p/demo/avl/rolist/gno.mod @@ -0,0 +1 @@ +module gno.land/p/demo/avl/rolist diff --git a/examples/gno.land/p/demo/avl/rolist/rolist.gno b/examples/gno.land/p/demo/avl/rolist/rolist.gno new file mode 100644 index 00000000000..23a85d9c885 --- /dev/null +++ b/examples/gno.land/p/demo/avl/rolist/rolist.gno @@ -0,0 +1,119 @@ +// Package rolist provides a read-only wrapper for list.List with safe value transformation. +// +// It is useful when you want to expose a read-only view of a list while ensuring that +// the sensitive data cannot be modified. +// +// Example: +// +// // Define a user structure with sensitive data +// type User struct { +// Name string +// Balance int +// Internal string // sensitive field +// } +// +// // Create and populate the original list +// privateList := list.New() +// privateList.Append(&User{ +// Name: "Alice", +// Balance: 100, +// Internal: "sensitive", +// }) +// +// // Create a safe transformation function that copies the struct +// // while excluding sensitive data +// makeEntrySafeFn := func(v interface{}) interface{} { +// u := v.(*User) +// return &User{ +// Name: u.Name, +// Balance: u.Balance, +// Internal: "", // omit sensitive data +// } +// } +// +// // Create a read-only view of the list +// publicList := rolist.Wrap(list, makeEntrySafeFn) +// +// // Safely access the data +// value := publicList.Get(0) +// user := value.(*User) +// // user.Name == "Alice" +// // user.Balance == 100 +// // user.Internal == "" (sensitive data is filtered) +package rolist + +import ( + "gno.land/p/demo/avl/list" +) + +// IReadOnlyList defines the read-only operations available on a list. +type IReadOnlyList interface { + Len() int + Get(index int) interface{} + Slice(startIndex, endIndex int) []interface{} + ForEach(fn func(index int, value interface{}) bool) +} + +// ReadOnlyList wraps a list.List and provides read-only access. +type ReadOnlyList struct { + list *list.List + makeEntrySafeFn func(interface{}) interface{} +} + +// Verify interface implementations +var _ IReadOnlyList = (*ReadOnlyList)(nil) +var _ IReadOnlyList = (interface{ list.IList })(nil) // is subset of list.IList + +// Wrap creates a new ReadOnlyList from an existing list.List and a safety transformation function. +// If makeEntrySafeFn is nil, values will be returned as-is without transformation. +func Wrap(list *list.List, makeEntrySafeFn func(interface{}) interface{}) *ReadOnlyList { + return &ReadOnlyList{ + list: list, + makeEntrySafeFn: makeEntrySafeFn, + } +} + +// getSafeValue applies the makeEntrySafeFn if it exists, otherwise returns the original value +func (rol *ReadOnlyList) getSafeValue(value interface{}) interface{} { + if rol.makeEntrySafeFn == nil { + return value + } + return rol.makeEntrySafeFn(value) +} + +// Len returns the number of elements in the list. +func (rol *ReadOnlyList) Len() int { + return rol.list.Len() +} + +// Get returns the value at the specified index, converted to a safe format. +// Returns nil if index is out of bounds. +func (rol *ReadOnlyList) Get(index int) interface{} { + value := rol.list.Get(index) + if value == nil { + return nil + } + return rol.getSafeValue(value) +} + +// Slice returns a slice of values from startIndex (inclusive) to endIndex (exclusive), +// with all values converted to a safe format. +func (rol *ReadOnlyList) Slice(startIndex, endIndex int) []interface{} { + values := rol.list.Slice(startIndex, endIndex) + if values == nil { + return nil + } + + result := make([]interface{}, len(values)) + for i, v := range values { + result[i] = rol.getSafeValue(v) + } + return result +} + +// ForEach iterates through all elements in the list, providing safe versions of the values. +func (rol *ReadOnlyList) ForEach(fn func(index int, value interface{}) bool) { + rol.list.ForEach(func(index int, value interface{}) bool { + return fn(index, rol.getSafeValue(value)) + }) +} diff --git a/examples/gno.land/p/demo/avl/rolist/rolist_test.gno b/examples/gno.land/p/demo/avl/rolist/rolist_test.gno new file mode 100644 index 00000000000..03b0a8cba30 --- /dev/null +++ b/examples/gno.land/p/demo/avl/rolist/rolist_test.gno @@ -0,0 +1,162 @@ +package rolist + +import ( + "testing" + + "gno.land/p/demo/avl/list" +) + +func TestExample(t *testing.T) { + // User represents our internal data structure + type User struct { + ID string + Name string + Balance int + Internal string // sensitive internal data + } + + // Create and populate the original list + l := &list.List{} + l.Append( + &User{ + ID: "1", + Name: "Alice", + Balance: 100, + Internal: "sensitive_data_1", + }, + &User{ + ID: "2", + Name: "Bob", + Balance: 200, + Internal: "sensitive_data_2", + }, + ) + + // Define a makeEntrySafeFn that: + // 1. Creates a defensive copy of the User struct + // 2. Omits sensitive internal data + makeEntrySafeFn := func(v interface{}) interface{} { + originalUser := v.(*User) + return &User{ + ID: originalUser.ID, + Name: originalUser.Name, + Balance: originalUser.Balance, + Internal: "", // Omit sensitive data + } + } + + // Create a read-only view of the list + roList := Wrap(l, makeEntrySafeFn) + + // Test retrieving and verifying a user + t.Run("Get User", func(t *testing.T) { + // Get user from read-only list + value := roList.Get(0) + if value == nil { + t.Fatal("User at index 0 not found") + } + + user := value.(*User) + + // Verify user data is correct + if user.Name != "Alice" || user.Balance != 100 { + t.Errorf("Unexpected user data: got name=%s balance=%d", user.Name, user.Balance) + } + + // Verify sensitive data is not exposed + if user.Internal != "" { + t.Error("Sensitive data should not be exposed") + } + + // Verify it's a different instance than the original + originalUser := l.Get(0).(*User) + if user == originalUser { + t.Error("Read-only list should return a copy, not the original pointer") + } + }) + + // Test slice functionality + t.Run("Slice Users", func(t *testing.T) { + users := roList.Slice(0, 2) + if len(users) != 2 { + t.Fatalf("Expected 2 users, got %d", len(users)) + } + + for _, v := range users { + user := v.(*User) + if user.Internal != "" { + t.Error("Sensitive data exposed in slice") + } + } + }) + + // Test ForEach functionality + t.Run("ForEach Users", func(t *testing.T) { + count := 0 + roList.ForEach(func(index int, value interface{}) bool { + user := value.(*User) + if user.Internal != "" { + t.Error("Sensitive data exposed during iteration") + } + count++ + return false + }) + + if count != 2 { + t.Errorf("Expected 2 users, got %d", count) + } + }) +} + +func TestNilMakeEntrySafeFn(t *testing.T) { + // Create a list with some test data + l := &list.List{} + originalValue := []int{1, 2, 3} + l.Append(originalValue) + + // Create a ReadOnlyList with nil makeEntrySafeFn + roList := Wrap(l, nil) + + // Test that we get back the original value + value := roList.Get(0) + if value == nil { + t.Fatal("Value not found") + } + + // Verify it's the exact same slice (not a copy) + retrievedSlice := value.([]int) + if &retrievedSlice[0] != &originalValue[0] { + t.Error("Expected to get back the original slice reference") + } +} + +func TestReadOnlyList(t *testing.T) { + // Example of a makeEntrySafeFn that appends "_readonly" to demonstrate transformation + makeEntrySafeFn := func(value interface{}) interface{} { + return value.(string) + "_readonly" + } + + l := &list.List{} + l.Append("value1", "value2", "value3") + + roList := Wrap(l, makeEntrySafeFn) + + tests := []struct { + name string + index int + expected interface{} + }{ + {"ExistingIndex0", 0, "value1_readonly"}, + {"ExistingIndex1", 1, "value2_readonly"}, + {"NonExistingIndex", 3, nil}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + value := roList.Get(tt.index) + if value != tt.expected { + t.Errorf("For index %d, expected %v, got %v", tt.index, tt.expected, value) + } + }) + } +} diff --git a/examples/gno.land/p/demo/avl/rotree/gno.mod b/examples/gno.land/p/demo/avl/rotree/gno.mod new file mode 100644 index 00000000000..d2cb439b2eb --- /dev/null +++ b/examples/gno.land/p/demo/avl/rotree/gno.mod @@ -0,0 +1 @@ +module gno.land/p/demo/avl/rotree diff --git a/examples/gno.land/p/demo/avl/rotree/rotree.gno b/examples/gno.land/p/demo/avl/rotree/rotree.gno new file mode 100644 index 00000000000..17cb4e20ced --- /dev/null +++ b/examples/gno.land/p/demo/avl/rotree/rotree.gno @@ -0,0 +1,177 @@ +// Package rotree provides a read-only wrapper for avl.Tree with safe value transformation. +// +// It is useful when you want to expose a read-only view of a tree while ensuring that +// the sensitive data cannot be modified. +// +// Example: +// +// // Define a user structure with sensitive data +// type User struct { +// Name string +// Balance int +// Internal string // sensitive field +// } +// +// // Create and populate the original tree +// privateTree := avl.NewTree() +// privateTree.Set("alice", &User{ +// Name: "Alice", +// Balance: 100, +// Internal: "sensitive", +// }) +// +// // Create a safe transformation function that copies the struct +// // while excluding sensitive data +// makeEntrySafeFn := func(v interface{}) interface{} { +// u := v.(*User) +// return &User{ +// Name: u.Name, +// Balance: u.Balance, +// Internal: "", // omit sensitive data +// } +// } +// +// // Create a read-only view of the tree +// PublicTree := rotree.Wrap(tree, makeEntrySafeFn) +// +// // Safely access the data +// value, _ := roTree.Get("alice") +// user := value.(*User) +// // user.Name == "Alice" +// // user.Balance == 100 +// // user.Internal == "" (sensitive data is filtered) +package rotree + +import ( + "gno.land/p/demo/avl" +) + +// Wrap creates a new ReadOnlyTree from an existing avl.Tree and a safety transformation function. +// If makeEntrySafeFn is nil, values will be returned as-is without transformation. +// +// makeEntrySafeFn is a function that transforms a tree entry into a safe version that can be exposed to external users. +// This function should be implemented based on the specific safety requirements of your use case: +// +// 1. No-op transformation: For primitive types (int, string, etc.) or already safe objects, +// simply pass nil as the makeEntrySafeFn to return values as-is. +// +// 2. Defensive copying: For mutable types like slices or maps, you should create a deep copy +// to prevent modification of the original data. +// Example: func(v interface{}) interface{} { return append([]int{}, v.([]int)...) } +// +// 3. Read-only wrapper: Return a read-only version of the object that implements +// a limited interface. +// Example: func(v interface{}) interface{} { return NewReadOnlyObject(v) } +// +// 4. DAO transformation: Transform the object into a data access object that +// controls how the underlying data can be accessed. +// Example: func(v interface{}) interface{} { return NewDAO(v) } +// +// The function ensures that the returned object is safe to expose to untrusted code, +// preventing unauthorized modifications to the original data structure. +func Wrap(tree *avl.Tree, makeEntrySafeFn func(interface{}) interface{}) *ReadOnlyTree { + return &ReadOnlyTree{ + tree: tree, + makeEntrySafeFn: makeEntrySafeFn, + } +} + +// ReadOnlyTree wraps an avl.Tree and provides read-only access. +type ReadOnlyTree struct { + tree *avl.Tree + makeEntrySafeFn func(interface{}) interface{} +} + +// IReadOnlyTree defines the read-only operations available on a tree. +type IReadOnlyTree interface { + Size() int + Has(key string) bool + Get(key string) (interface{}, bool) + GetByIndex(index int) (string, interface{}) + Iterate(start, end string, cb avl.IterCbFn) bool + ReverseIterate(start, end string, cb avl.IterCbFn) bool + IterateByOffset(offset int, count int, cb avl.IterCbFn) bool + ReverseIterateByOffset(offset int, count int, cb avl.IterCbFn) bool +} + +// Verify that ReadOnlyTree implements both ITree and IReadOnlyTree +var ( + _ avl.ITree = (*ReadOnlyTree)(nil) + _ IReadOnlyTree = (*ReadOnlyTree)(nil) +) + +// getSafeValue applies the makeEntrySafeFn if it exists, otherwise returns the original value +func (roTree *ReadOnlyTree) getSafeValue(value interface{}) interface{} { + if roTree.makeEntrySafeFn == nil { + return value + } + return roTree.makeEntrySafeFn(value) +} + +// Size returns the number of key-value pairs in the tree. +func (roTree *ReadOnlyTree) Size() int { + return roTree.tree.Size() +} + +// Has checks whether a key exists in the tree. +func (roTree *ReadOnlyTree) Has(key string) bool { + return roTree.tree.Has(key) +} + +// Get retrieves the value associated with the given key, converted to a safe format. +func (roTree *ReadOnlyTree) Get(key string) (interface{}, bool) { + value, exists := roTree.tree.Get(key) + if !exists { + return nil, false + } + return roTree.getSafeValue(value), true +} + +// GetByIndex retrieves the key-value pair at the specified index in the tree, with the value converted to a safe format. +func (roTree *ReadOnlyTree) GetByIndex(index int) (string, interface{}) { + key, value := roTree.tree.GetByIndex(index) + return key, roTree.getSafeValue(value) +} + +// Iterate performs an in-order traversal of the tree within the specified key range. +func (roTree *ReadOnlyTree) Iterate(start, end string, cb avl.IterCbFn) bool { + return roTree.tree.Iterate(start, end, func(key string, value interface{}) bool { + return cb(key, roTree.getSafeValue(value)) + }) +} + +// ReverseIterate performs a reverse in-order traversal of the tree within the specified key range. +func (roTree *ReadOnlyTree) ReverseIterate(start, end string, cb avl.IterCbFn) bool { + return roTree.tree.ReverseIterate(start, end, func(key string, value interface{}) bool { + return cb(key, roTree.getSafeValue(value)) + }) +} + +// IterateByOffset performs an in-order traversal of the tree starting from the specified offset. +func (roTree *ReadOnlyTree) IterateByOffset(offset int, count int, cb avl.IterCbFn) bool { + return roTree.tree.IterateByOffset(offset, count, func(key string, value interface{}) bool { + return cb(key, roTree.getSafeValue(value)) + }) +} + +// ReverseIterateByOffset performs a reverse in-order traversal of the tree starting from the specified offset. +func (roTree *ReadOnlyTree) ReverseIterateByOffset(offset int, count int, cb avl.IterCbFn) bool { + return roTree.tree.ReverseIterateByOffset(offset, count, func(key string, value interface{}) bool { + return cb(key, roTree.getSafeValue(value)) + }) +} + +// Set is not supported on ReadOnlyTree and will panic. +func (roTree *ReadOnlyTree) Set(key string, value interface{}) bool { + panic("Set operation not supported on ReadOnlyTree") +} + +// Remove is not supported on ReadOnlyTree and will panic. +func (roTree *ReadOnlyTree) Remove(key string) (value interface{}, removed bool) { + panic("Remove operation not supported on ReadOnlyTree") +} + +// RemoveByIndex is not supported on ReadOnlyTree and will panic. +func (roTree *ReadOnlyTree) RemoveByIndex(index int) (key string, value interface{}) { + panic("RemoveByIndex operation not supported on ReadOnlyTree") +} diff --git a/examples/gno.land/p/demo/avl/rotree/rotree_test.gno b/examples/gno.land/p/demo/avl/rotree/rotree_test.gno new file mode 100644 index 00000000000..fbc14bd688d --- /dev/null +++ b/examples/gno.land/p/demo/avl/rotree/rotree_test.gno @@ -0,0 +1,222 @@ +package rotree + +import ( + "testing" + + "gno.land/p/demo/avl" +) + +func TestExample(t *testing.T) { + // User represents our internal data structure + type User struct { + ID string + Name string + Balance int + Internal string // sensitive internal data + } + + // Create and populate the original tree with user pointers + tree := avl.NewTree() + tree.Set("alice", &User{ + ID: "1", + Name: "Alice", + Balance: 100, + Internal: "sensitive_data_1", + }) + tree.Set("bob", &User{ + ID: "2", + Name: "Bob", + Balance: 200, + Internal: "sensitive_data_2", + }) + + // Define a makeEntrySafeFn that: + // 1. Creates a defensive copy of the User struct + // 2. Omits sensitive internal data + makeEntrySafeFn := func(v interface{}) interface{} { + originalUser := v.(*User) + return &User{ + ID: originalUser.ID, + Name: originalUser.Name, + Balance: originalUser.Balance, + Internal: "", // Omit sensitive data + } + } + + // Create a read-only view of the tree + roTree := Wrap(tree, makeEntrySafeFn) + + // Test retrieving and verifying a user + t.Run("Get User", func(t *testing.T) { + // Get user from read-only tree + value, exists := roTree.Get("alice") + if !exists { + t.Fatal("User 'alice' not found") + } + + user := value.(*User) + + // Verify user data is correct + if user.Name != "Alice" || user.Balance != 100 { + t.Errorf("Unexpected user data: got name=%s balance=%d", user.Name, user.Balance) + } + + // Verify sensitive data is not exposed + if user.Internal != "" { + t.Error("Sensitive data should not be exposed") + } + + // Verify it's a different instance than the original + originalValue, _ := tree.Get("alice") + originalUser := originalValue.(*User) + if user == originalUser { + t.Error("Read-only tree should return a copy, not the original pointer") + } + }) + + // Test iterating over users + t.Run("Iterate Users", func(t *testing.T) { + count := 0 + roTree.Iterate("", "", func(key string, value interface{}) bool { + user := value.(*User) + // Verify each user has empty Internal field + if user.Internal != "" { + t.Error("Sensitive data exposed during iteration") + } + count++ + return false + }) + + if count != 2 { + t.Errorf("Expected 2 users, got %d", count) + } + }) + + // Verify that modifications to the returned user don't affect the original + t.Run("Modification Safety", func(t *testing.T) { + value, _ := roTree.Get("alice") + user := value.(*User) + + // Try to modify the returned user + user.Balance = 999 + user.Internal = "hacked" + + // Verify original is unchanged + originalValue, _ := tree.Get("alice") + originalUser := originalValue.(*User) + if originalUser.Balance != 100 || originalUser.Internal != "sensitive_data_1" { + t.Error("Original user data was modified") + } + }) +} + +func TestReadOnlyTree(t *testing.T) { + // Example of a makeEntrySafeFn that appends "_readonly" to demonstrate transformation + makeEntrySafeFn := func(value interface{}) interface{} { + return value.(string) + "_readonly" + } + + tree := avl.NewTree() + tree.Set("key1", "value1") + tree.Set("key2", "value2") + tree.Set("key3", "value3") + + roTree := Wrap(tree, makeEntrySafeFn) + + tests := []struct { + name string + key string + expected interface{} + exists bool + }{ + {"ExistingKey1", "key1", "value1_readonly", true}, + {"ExistingKey2", "key2", "value2_readonly", true}, + {"NonExistingKey", "key4", nil, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + value, exists := roTree.Get(tt.key) + if exists != tt.exists || value != tt.expected { + t.Errorf("For key %s, expected %v (exists: %v), got %v (exists: %v)", tt.key, tt.expected, tt.exists, value, exists) + } + }) + } +} + +// Add example tests showing different makeEntrySafeFn implementations +func TestMakeEntrySafeFnVariants(t *testing.T) { + tree := avl.NewTree() + tree.Set("slice", []int{1, 2, 3}) + tree.Set("map", map[string]int{"a": 1}) + + tests := []struct { + name string + makeEntrySafeFn func(interface{}) interface{} + key string + validate func(t *testing.T, value interface{}) + }{ + { + name: "Defensive Copy Slice", + makeEntrySafeFn: func(v interface{}) interface{} { + original := v.([]int) + return append([]int{}, original...) + }, + key: "slice", + validate: func(t *testing.T, value interface{}) { + slice := value.([]int) + // Modify the returned slice + slice[0] = 999 + // Verify original is unchanged + originalValue, _ := tree.Get("slice") + original := originalValue.([]int) + if original[0] != 1 { + t.Error("Original slice was modified") + } + }, + }, + // Add more test cases for different makeEntrySafeFn implementations + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + roTree := Wrap(tree, tt.makeEntrySafeFn) + value, exists := roTree.Get(tt.key) + if !exists { + t.Fatal("Key not found") + } + tt.validate(t, value) + }) + } +} + +func TestNilMakeEntrySafeFn(t *testing.T) { + // Create a tree with some test data + tree := avl.NewTree() + originalValue := []int{1, 2, 3} + tree.Set("test", originalValue) + + // Create a ReadOnlyTree with nil makeEntrySafeFn + roTree := Wrap(tree, nil) + + // Test that we get back the original value + value, exists := roTree.Get("test") + if !exists { + t.Fatal("Key not found") + } + + // Verify it's the exact same slice (not a copy) + retrievedSlice := value.([]int) + if &retrievedSlice[0] != &originalValue[0] { + t.Error("Expected to get back the original slice reference") + } + + // Test through iteration as well + roTree.Iterate("", "", func(key string, value interface{}) bool { + retrievedSlice := value.([]int) + if &retrievedSlice[0] != &originalValue[0] { + t.Error("Expected to get back the original slice reference in iteration") + } + return false + }) +} diff --git a/examples/gno.land/p/demo/avl/tree.gno b/examples/gno.land/p/demo/avl/tree.gno index e7aa55eb7e4..3834246d2cd 100644 --- a/examples/gno.land/p/demo/avl/tree.gno +++ b/examples/gno.land/p/demo/avl/tree.gno @@ -1,5 +1,23 @@ package avl +type ITree interface { + // read operations + + Size() int + Has(key string) bool + Get(key string) (value interface{}, exists bool) + GetByIndex(index int) (key string, value interface{}) + Iterate(start, end string, cb IterCbFn) bool + ReverseIterate(start, end string, cb IterCbFn) bool + IterateByOffset(offset int, count int, cb IterCbFn) bool + ReverseIterateByOffset(offset int, count int, cb IterCbFn) bool + + // write operations + + Set(key string, value interface{}) (updated bool) + Remove(key string) (value interface{}, removed bool) +} + type IterCbFn func(key string, value interface{}) bool //---------------------------------------- @@ -101,3 +119,6 @@ func (tree *Tree) ReverseIterateByOffset(offset int, count int, cb IterCbFn) boo }, ) } + +// Verify that Tree implements TreeInterface +var _ ITree = (*Tree)(nil) diff --git a/examples/gno.land/p/demo/avl/z_0_filetest.gno b/examples/gno.land/p/demo/avl/z_0_filetest.gno index 2dce5e7f1ac..1db1adebd3e 100644 --- a/examples/gno.land/p/demo/avl/z_0_filetest.gno +++ b/examples/gno.land/p/demo/avl/z_0_filetest.gno @@ -215,116 +215,3 @@ func main() { // } // } // } -// u[a8ada09dee16d791fd406d629fe29bb0ed084a30:2]={ -// "Blank": {}, -// "ObjectInfo": { -// "ID": "a8ada09dee16d791fd406d629fe29bb0ed084a30:2", -// "IsEscaped": true, -// "ModTime": "5", -// "RefCount": "2" -// }, -// "Parent": null, -// "Source": { -// "@type": "/gno.RefNode", -// "BlockNode": null, -// "Location": { -// "Column": "0", -// "File": "", -// "Line": "0", -// "PkgPath": "gno.land/r/test" -// } -// }, -// "Values": [ -// { -// "T": { -// "@type": "/gno.PointerType", -// "Elt": { -// "@type": "/gno.RefType", -// "ID": "gno.land/p/demo/avl.Node" -// } -// }, -// "V": { -// "@type": "/gno.PointerValue", -// "Base": { -// "@type": "/gno.RefValue", -// "Hash": "ae86874f9b47fa5e64c30b3e92e9d07f2ec967a4", -// "ObjectID": "a8ada09dee16d791fd406d629fe29bb0ed084a30:6" -// }, -// "Index": "0", -// "TV": null -// } -// }, -// { -// "T": { -// "@type": "/gno.FuncType", -// "Params": [], -// "Results": [] -// }, -// "V": { -// "@type": "/gno.FuncValue", -// "Closure": { -// "@type": "/gno.RefValue", -// "Escaped": true, -// "ObjectID": "a8ada09dee16d791fd406d629fe29bb0ed084a30:3" -// }, -// "FileName": "z_0.gno", -// "IsMethod": false, -// "Name": "init.1", -// "NativeName": "", -// "NativePkg": "", -// "PkgPath": "gno.land/r/test", -// "Source": { -// "@type": "/gno.RefNode", -// "BlockNode": null, -// "Location": { -// "Column": "1", -// "File": "z_0.gno", -// "Line": "10", -// "PkgPath": "gno.land/r/test" -// } -// }, -// "Type": { -// "@type": "/gno.FuncType", -// "Params": [], -// "Results": [] -// } -// } -// }, -// { -// "T": { -// "@type": "/gno.FuncType", -// "Params": [], -// "Results": [] -// }, -// "V": { -// "@type": "/gno.FuncValue", -// "Closure": { -// "@type": "/gno.RefValue", -// "Escaped": true, -// "ObjectID": "a8ada09dee16d791fd406d629fe29bb0ed084a30:3" -// }, -// "FileName": "z_0.gno", -// "IsMethod": false, -// "Name": "main", -// "NativeName": "", -// "NativePkg": "", -// "PkgPath": "gno.land/r/test", -// "Source": { -// "@type": "/gno.RefNode", -// "BlockNode": null, -// "Location": { -// "Column": "1", -// "File": "z_0.gno", -// "Line": "15", -// "PkgPath": "gno.land/r/test" -// } -// }, -// "Type": { -// "@type": "/gno.FuncType", -// "Params": [], -// "Results": [] -// } -// } -// } -// ] -// } diff --git a/examples/gno.land/p/demo/avl/z_1_filetest.gno b/examples/gno.land/p/demo/avl/z_1_filetest.gno index 97ca5ed2135..572c49333bc 100644 --- a/examples/gno.land/p/demo/avl/z_1_filetest.gno +++ b/examples/gno.land/p/demo/avl/z_1_filetest.gno @@ -24,6 +24,44 @@ func main() { // Realm: // switchrealm["gno.land/r/test"] +// u[a8ada09dee16d791fd406d629fe29bb0ed084a30:6]={ +// "ObjectInfo": { +// "ID": "a8ada09dee16d791fd406d629fe29bb0ed084a30:6", +// "ModTime": "11", +// "OwnerID": "a8ada09dee16d791fd406d629fe29bb0ed084a30:5", +// "RefCount": "1" +// }, +// "Value": { +// "T": { +// "@type": "/gno.RefType", +// "ID": "gno.land/p/demo/avl.Node" +// }, +// "V": { +// "@type": "/gno.RefValue", +// "Hash": "1375f6f96a1a3f298347dc8fc0065afa36cb7f0f", +// "ObjectID": "a8ada09dee16d791fd406d629fe29bb0ed084a30:7" +// } +// } +// } +// u[a8ada09dee16d791fd406d629fe29bb0ed084a30:8]={ +// "ObjectInfo": { +// "ID": "a8ada09dee16d791fd406d629fe29bb0ed084a30:8", +// "ModTime": "13", +// "OwnerID": "a8ada09dee16d791fd406d629fe29bb0ed084a30:5", +// "RefCount": "1" +// }, +// "Value": { +// "T": { +// "@type": "/gno.RefType", +// "ID": "gno.land/p/demo/avl.Node" +// }, +// "V": { +// "@type": "/gno.RefValue", +// "Hash": "b28057ab7be6383785c0a5503e8a531bdbc21851", +// "ObjectID": "a8ada09dee16d791fd406d629fe29bb0ed084a30:9" +// } +// } +// } // c[a8ada09dee16d791fd406d629fe29bb0ed084a30:15]={ // "Fields": [ // { @@ -143,7 +181,7 @@ func main() { // "@type": "/gno.PointerValue", // "Base": { // "@type": "/gno.RefValue", -// "Hash": "2f3adc5d0f2a3fe0331cfa93572a7abdde14c9aa", +// "Hash": "cafae89e4d4aaaefe7fdf0691084508d4274a981", // "ObjectID": "a8ada09dee16d791fd406d629fe29bb0ed084a30:8" // }, // "Index": "0", @@ -191,7 +229,7 @@ func main() { // }, // "V": { // "@type": "/gno.RefValue", -// "Hash": "fe20a19f956511f274dc77854e9e5468387260f4", +// "Hash": "b2e446f490656c19a83c43055de29c96e92a1549", // "ObjectID": "a8ada09dee16d791fd406d629fe29bb0ed084a30:13" // } // } @@ -235,7 +273,7 @@ func main() { // "@type": "/gno.PointerValue", // "Base": { // "@type": "/gno.RefValue", -// "Hash": "c89a71bdf045e8bde2059dc9d33839f916e02e5d", +// "Hash": "4e56eeb96eb1d9b27cf603140cd03a1622b6358b", // "ObjectID": "a8ada09dee16d791fd406d629fe29bb0ed084a30:6" // }, // "Index": "0", @@ -254,7 +292,7 @@ func main() { // "@type": "/gno.PointerValue", // "Base": { // "@type": "/gno.RefValue", -// "Hash": "90fa67f8c47db4b9b2a60425dff08d5a3385100f", +// "Hash": "7b61530859954d1d14b2f696c91c5f37d39c21e7", // "ObjectID": "a8ada09dee16d791fd406d629fe29bb0ed084a30:12" // }, // "Index": "0", @@ -283,123 +321,10 @@ func main() { // }, // "V": { // "@type": "/gno.RefValue", -// "Hash": "83e42caaf53070dd95b5f859053eb51ed900bbda", +// "Hash": "fedc6d430b38c985dc6a985b2fcaee97e88ba6da", // "ObjectID": "a8ada09dee16d791fd406d629fe29bb0ed084a30:11" // } // } // } -// u[a8ada09dee16d791fd406d629fe29bb0ed084a30:2]={ -// "Blank": {}, -// "ObjectInfo": { -// "ID": "a8ada09dee16d791fd406d629fe29bb0ed084a30:2", -// "IsEscaped": true, -// "ModTime": "9", -// "RefCount": "2" -// }, -// "Parent": null, -// "Source": { -// "@type": "/gno.RefNode", -// "BlockNode": null, -// "Location": { -// "Column": "0", -// "File": "", -// "Line": "0", -// "PkgPath": "gno.land/r/test" -// } -// }, -// "Values": [ -// { -// "T": { -// "@type": "/gno.PointerType", -// "Elt": { -// "@type": "/gno.RefType", -// "ID": "gno.land/p/demo/avl.Node" -// } -// }, -// "V": { -// "@type": "/gno.PointerValue", -// "Base": { -// "@type": "/gno.RefValue", -// "Hash": "1faa9fa4ba1935121a6d3f0a623772e9d4499b0a", -// "ObjectID": "a8ada09dee16d791fd406d629fe29bb0ed084a30:10" -// }, -// "Index": "0", -// "TV": null -// } -// }, -// { -// "T": { -// "@type": "/gno.FuncType", -// "Params": [], -// "Results": [] -// }, -// "V": { -// "@type": "/gno.FuncValue", -// "Closure": { -// "@type": "/gno.RefValue", -// "Escaped": true, -// "ObjectID": "a8ada09dee16d791fd406d629fe29bb0ed084a30:3" -// }, -// "FileName": "z_1.gno", -// "IsMethod": false, -// "Name": "init.1", -// "NativeName": "", -// "NativePkg": "", -// "PkgPath": "gno.land/r/test", -// "Source": { -// "@type": "/gno.RefNode", -// "BlockNode": null, -// "Location": { -// "Column": "1", -// "File": "z_1.gno", -// "Line": "10", -// "PkgPath": "gno.land/r/test" -// } -// }, -// "Type": { -// "@type": "/gno.FuncType", -// "Params": [], -// "Results": [] -// } -// } -// }, -// { -// "T": { -// "@type": "/gno.FuncType", -// "Params": [], -// "Results": [] -// }, -// "V": { -// "@type": "/gno.FuncValue", -// "Closure": { -// "@type": "/gno.RefValue", -// "Escaped": true, -// "ObjectID": "a8ada09dee16d791fd406d629fe29bb0ed084a30:3" -// }, -// "FileName": "z_1.gno", -// "IsMethod": false, -// "Name": "main", -// "NativeName": "", -// "NativePkg": "", -// "PkgPath": "gno.land/r/test", -// "Source": { -// "@type": "/gno.RefNode", -// "BlockNode": null, -// "Location": { -// "Column": "1", -// "File": "z_1.gno", -// "Line": "15", -// "PkgPath": "gno.land/r/test" -// } -// }, -// "Type": { -// "@type": "/gno.FuncType", -// "Params": [], -// "Results": [] -// } -// } -// } -// ] -// } // d[a8ada09dee16d791fd406d629fe29bb0ed084a30:4] // d[a8ada09dee16d791fd406d629fe29bb0ed084a30:5] diff --git a/examples/gno.land/p/demo/avl/z_2_filetest.gno b/examples/gno.land/p/demo/avl/z_2_filetest.gno index 43067c31e8f..c45088075d6 100644 --- a/examples/gno.land/p/demo/avl/z_2_filetest.gno +++ b/examples/gno.land/p/demo/avl/z_2_filetest.gno @@ -23,6 +23,44 @@ func main() { // Realm: // switchrealm["gno.land/r/test"] +// u[a8ada09dee16d791fd406d629fe29bb0ed084a30:7]={ +// "ObjectInfo": { +// "ID": "a8ada09dee16d791fd406d629fe29bb0ed084a30:7", +// "ModTime": "12", +// "OwnerID": "a8ada09dee16d791fd406d629fe29bb0ed084a30:6", +// "RefCount": "1" +// }, +// "Value": { +// "T": { +// "@type": "/gno.RefType", +// "ID": "gno.land/p/demo/avl.Node" +// }, +// "V": { +// "@type": "/gno.RefValue", +// "Hash": "ba7550123807b8da857e38b72f66204b1ec582a2", +// "ObjectID": "a8ada09dee16d791fd406d629fe29bb0ed084a30:8" +// } +// } +// } +// u[a8ada09dee16d791fd406d629fe29bb0ed084a30:9]={ +// "ObjectInfo": { +// "ID": "a8ada09dee16d791fd406d629fe29bb0ed084a30:9", +// "ModTime": "14", +// "OwnerID": "a8ada09dee16d791fd406d629fe29bb0ed084a30:6", +// "RefCount": "1" +// }, +// "Value": { +// "T": { +// "@type": "/gno.RefType", +// "ID": "gno.land/p/demo/avl.Node" +// }, +// "V": { +// "@type": "/gno.RefValue", +// "Hash": "3cb8485664c356fcb5c88dfb96b7455133a6b022", +// "ObjectID": "a8ada09dee16d791fd406d629fe29bb0ed084a30:10" +// } +// } +// } // c[a8ada09dee16d791fd406d629fe29bb0ed084a30:16]={ // "Fields": [ // { @@ -142,7 +180,7 @@ func main() { // "@type": "/gno.PointerValue", // "Base": { // "@type": "/gno.RefValue", -// "Hash": "849a50d6c78d65742752e3c89ad8dd556e2e63cb", +// "Hash": "db39c9c0a60e0d5b30dbaf9be6150d3fec16aa4b", // "ObjectID": "a8ada09dee16d791fd406d629fe29bb0ed084a30:9" // }, // "Index": "0", @@ -190,7 +228,7 @@ func main() { // }, // "V": { // "@type": "/gno.RefValue", -// "Hash": "a1160b0060ad752dbfe5fe436f7734bb19136150", +// "Hash": "2e9127534f91b385426d76e8e164f50f635cc1de", // "ObjectID": "a8ada09dee16d791fd406d629fe29bb0ed084a30:14" // } // } @@ -234,7 +272,7 @@ func main() { // "@type": "/gno.PointerValue", // "Base": { // "@type": "/gno.RefValue", -// "Hash": "fd95e08763159ac529e26986d652e752e78b6325", +// "Hash": "43e03b0c877b40c34e12bc2b15560e8ecd42ae9d", // "ObjectID": "a8ada09dee16d791fd406d629fe29bb0ed084a30:7" // }, // "Index": "0", @@ -253,7 +291,7 @@ func main() { // "@type": "/gno.PointerValue", // "Base": { // "@type": "/gno.RefValue", -// "Hash": "3ecdcf148fe2f9e97b72a3bedf303b2ba56d4f4b", +// "Hash": "4b123e2424d900a427f9dee88a70ce61f3cdcf5b", // "ObjectID": "a8ada09dee16d791fd406d629fe29bb0ed084a30:13" // }, // "Index": "0", @@ -282,7 +320,7 @@ func main() { // }, // "V": { // "@type": "/gno.RefValue", -// "Hash": "63126557dba88f8556f7a0ccbbfc1d218ae7a302", +// "Hash": "76d9227e755efd6674d8fa34e12decb7a9855488", // "ObjectID": "a8ada09dee16d791fd406d629fe29bb0ed084a30:12" // } // } @@ -301,7 +339,7 @@ func main() { // "@type": "/gno.PointerValue", // "Base": { // "@type": "/gno.RefValue", -// "Hash": "d31c7e797793e03ffe0bbcb72f963264f8300d22", +// "Hash": "ff46b4dd63457c3fd59801e725f65af524ec829d", // "ObjectID": "a8ada09dee16d791fd406d629fe29bb0ed084a30:11" // }, // "Index": "0", diff --git a/examples/gno.land/p/demo/avlhelpers/avlhelpers.gno b/examples/gno.land/p/demo/avlhelpers/avlhelpers.gno index 27842932dd3..e5fe33cacad 100644 --- a/examples/gno.land/p/demo/avlhelpers/avlhelpers.gno +++ b/examples/gno.land/p/demo/avlhelpers/avlhelpers.gno @@ -8,7 +8,7 @@ import ( // It calls the provided callback function for each key-value pair encountered. // If the callback returns true, the iteration is stopped. // The prefix and keys are treated as byte strings, ignoring possible multi-byte Unicode runes. -func IterateByteStringKeysByPrefix(tree avl.Tree, prefix string, cb avl.IterCbFn) { +func IterateByteStringKeysByPrefix(tree avl.ITree, prefix string, cb avl.IterCbFn) { end := "" n := len(prefix) // To make the end of the search, increment the final character ASCII by one. @@ -28,7 +28,7 @@ func IterateByteStringKeysByPrefix(tree avl.Tree, prefix string, cb avl.IterCbFn // Get a list of keys starting from the given prefix. Limit the // number of results to maxResults. // The prefix and keys are treated as byte strings, ignoring possible multi-byte Unicode runes. -func ListByteStringKeysByPrefix(tree avl.Tree, prefix string, maxResults int) []string { +func ListByteStringKeysByPrefix(tree avl.ITree, prefix string, maxResults int) []string { result := []string{} IterateByteStringKeysByPrefix(tree, prefix, func(key string, value interface{}) bool { result = append(result, key) diff --git a/examples/gno.land/p/demo/avlhelpers/z_0_filetest.gno b/examples/gno.land/p/demo/avlhelpers/z_0_filetest.gno index 1c7873e297a..5ecda41d1a6 100644 --- a/examples/gno.land/p/demo/avlhelpers/z_0_filetest.gno +++ b/examples/gno.land/p/demo/avlhelpers/z_0_filetest.gno @@ -10,7 +10,7 @@ import ( ) func main() { - tree := avl.Tree{} + tree := avl.NewTree() { // Empty tree. @@ -44,7 +44,7 @@ func main() { println("match: " + matches[0]) } - tree = avl.Tree{} + tree = avl.NewTree() tree.Set("a\xff", "") tree.Set("a\xff\xff", "") tree.Set("b", "") diff --git a/examples/gno.land/p/demo/grc/grc20/token.gno b/examples/gno.land/p/demo/grc/grc20/token.gno index 4634bae933b..3ab3abc63a3 100644 --- a/examples/gno.land/p/demo/grc/grc20/token.gno +++ b/examples/gno.land/p/demo/grc/grc20/token.gno @@ -65,6 +65,14 @@ func (tok *Token) RenderHome() string { return str } +// Getter returns a TokenGetter function that returns this token. This allows +// storing indirect pointers to a token in a remote realm. +func (tok *Token) Getter() TokenGetter { + return func() *Token { + return tok + } +} + // SpendAllowance decreases the allowance of the specified owner and spender. func (led *PrivateLedger) SpendAllowance(owner, spender std.Address, amount uint64) error { if !owner.IsValid() { diff --git a/examples/gno.land/p/demo/memeland/memeland.gno b/examples/gno.land/p/demo/memeland/memeland.gno index 9c302ca365b..38f42239bec 100644 --- a/examples/gno.land/p/demo/memeland/memeland.gno +++ b/examples/gno.land/p/demo/memeland/memeland.gno @@ -160,8 +160,8 @@ func (m *Memeland) RemovePost(id string) string { panic("id cannot be empty") } - if err := m.CallerIsOwner(); err != nil { - panic(err) + if !m.CallerIsOwner() { + panic(ownable.ErrUnauthorized) } for i, post := range m.Posts { diff --git a/examples/gno.land/p/demo/ownable/exts/authorizable/authorizable.gno b/examples/gno.land/p/demo/ownable/exts/authorizable/authorizable.gno index f9f0ea15dd9..95bd2ac4959 100644 --- a/examples/gno.land/p/demo/ownable/exts/authorizable/authorizable.gno +++ b/examples/gno.land/p/demo/ownable/exts/authorizable/authorizable.gno @@ -41,7 +41,7 @@ func NewAuthorizableWithAddress(addr std.Address) *Authorizable { } func (a *Authorizable) AddToAuthList(addr std.Address) error { - if err := a.CallerIsOwner(); err != nil { + if !a.CallerIsOwner() { return ErrNotSuperuser } @@ -55,7 +55,7 @@ func (a *Authorizable) AddToAuthList(addr std.Address) error { } func (a *Authorizable) DeleteFromAuthList(addr std.Address) error { - if err := a.CallerIsOwner(); err != nil { + if !a.CallerIsOwner() { return ErrNotSuperuser } diff --git a/examples/gno.land/p/demo/ownable/ownable.gno b/examples/gno.land/p/demo/ownable/ownable.gno index 48a1c15fffa..f565e27c0f2 100644 --- a/examples/gno.land/p/demo/ownable/ownable.gno +++ b/examples/gno.land/p/demo/ownable/ownable.gno @@ -6,6 +6,7 @@ const OwnershipTransferEvent = "OwnershipTransfer" // Ownable is meant to be used as a top-level object to make your contract ownable OR // being embedded in a Gno object to manage per-object ownership. +// Ownable is safe to export as a top-level object type Ownable struct { owner std.Address } @@ -24,9 +25,8 @@ func NewWithAddress(addr std.Address) *Ownable { // TransferOwnership transfers ownership of the Ownable struct to a new address func (o *Ownable) TransferOwnership(newOwner std.Address) error { - err := o.CallerIsOwner() - if err != nil { - return err + if !o.CallerIsOwner() { + return ErrUnauthorized } if !newOwner.IsValid() { @@ -48,9 +48,8 @@ func (o *Ownable) TransferOwnership(newOwner std.Address) error { // Top-level usage: disables all only-owner actions/functions, // Embedded usage: behaves like a burn functionality, removing the owner from the struct func (o *Ownable) DropOwnership() error { - err := o.CallerIsOwner() - if err != nil { - return err + if !o.CallerIsOwner() { + return ErrUnauthorized } prevOwner := o.owner @@ -71,12 +70,8 @@ func (o Ownable) Owner() std.Address { } // CallerIsOwner checks if the caller of the function is the Realm's owner -func (o Ownable) CallerIsOwner() error { - if std.PrevRealm().Addr() == o.owner { - return nil - } - - return ErrUnauthorized +func (o Ownable) CallerIsOwner() bool { + return std.PrevRealm().Addr() == o.owner } // AssertCallerIsOwner panics if the caller is not the owner diff --git a/examples/gno.land/p/demo/ownable/ownable_test.gno b/examples/gno.land/p/demo/ownable/ownable_test.gno index dee40fa6e1d..f58af9642c6 100644 --- a/examples/gno.land/p/demo/ownable/ownable_test.gno +++ b/examples/gno.land/p/demo/ownable/ownable_test.gno @@ -6,6 +6,7 @@ import ( "gno.land/p/demo/testutils" "gno.land/p/demo/uassert" + "gno.land/p/demo/urequire" ) var ( @@ -19,18 +20,14 @@ func TestNew(t *testing.T) { o := New() got := o.Owner() - if alice != got { - t.Fatalf("Expected %s, got: %s", alice, got) - } + uassert.Equal(t, got, alice) } func TestNewWithAddress(t *testing.T) { o := NewWithAddress(alice) got := o.Owner() - if alice != got { - t.Fatalf("Expected %s, got: %s", alice, got) - } + uassert.Equal(t, got, alice) } func TestTransferOwnership(t *testing.T) { @@ -39,14 +36,11 @@ func TestTransferOwnership(t *testing.T) { o := New() err := o.TransferOwnership(bob) - if err != nil { - t.Fatalf("TransferOwnership failed, %v", err) - } + urequire.NoError(t, err) got := o.Owner() - if bob != got { - t.Fatalf("Expected: %s, got: %s", bob, got) - } + + uassert.Equal(t, got, bob) } func TestCallerIsOwner(t *testing.T) { @@ -58,8 +52,7 @@ func TestCallerIsOwner(t *testing.T) { std.TestSetRealm(std.NewUserRealm(unauthorizedCaller)) std.TestSetOrigCaller(unauthorizedCaller) // TODO(bug): should not be needed - err := o.CallerIsOwner() - uassert.Error(t, err) // XXX: IsError(..., unauthorizedCaller) + uassert.False(t, o.CallerIsOwner()) } func TestDropOwnership(t *testing.T) { @@ -68,7 +61,7 @@ func TestDropOwnership(t *testing.T) { o := New() err := o.DropOwnership() - uassert.NoError(t, err, "DropOwnership failed") + urequire.NoError(t, err, "DropOwnership failed") owner := o.Owner() uassert.Empty(t, owner, "owner should be empty") @@ -85,13 +78,8 @@ func TestErrUnauthorized(t *testing.T) { std.TestSetRealm(std.NewUserRealm(bob)) std.TestSetOrigCaller(bob) // TODO(bug): should not be needed - err := o.TransferOwnership(alice) - if err != ErrUnauthorized { - t.Fatalf("Should've been ErrUnauthorized, was %v", err) - } - - err = o.DropOwnership() - uassert.ErrorContains(t, err, ErrUnauthorized.Error()) + uassert.ErrorContains(t, o.TransferOwnership(alice), ErrUnauthorized.Error()) + uassert.ErrorContains(t, o.DropOwnership(), ErrUnauthorized.Error()) } func TestErrInvalidAddress(t *testing.T) { diff --git a/examples/gno.land/p/demo/pausable/pausable.gno b/examples/gno.land/p/demo/pausable/pausable.gno index e9cce63c1e3..e6a85771fa6 100644 --- a/examples/gno.land/p/demo/pausable/pausable.gno +++ b/examples/gno.land/p/demo/pausable/pausable.gno @@ -34,8 +34,8 @@ func (p Pausable) IsPaused() bool { // Pause sets the state of Pausable to true, meaning all pausable functions are paused func (p *Pausable) Pause() error { - if err := p.CallerIsOwner(); err != nil { - return err + if !p.CallerIsOwner() { + return ownable.ErrUnauthorized } p.paused = true @@ -46,8 +46,8 @@ func (p *Pausable) Pause() error { // Unpause sets the state of Pausable to false, meaning all pausable functions are resumed func (p *Pausable) Unpause() error { - if err := p.CallerIsOwner(); err != nil { - return err + if !p.CallerIsOwner() { + return ownable.ErrUnauthorized } p.paused = false diff --git a/examples/gno.land/p/demo/subscription/lifetime/lifetime.gno b/examples/gno.land/p/demo/subscription/lifetime/lifetime.gno index 8a4c10b687b..be661e70129 100644 --- a/examples/gno.land/p/demo/subscription/lifetime/lifetime.gno +++ b/examples/gno.land/p/demo/subscription/lifetime/lifetime.gno @@ -67,7 +67,7 @@ func (ls *LifetimeSubscription) HasValidSubscription(addr std.Address) error { // UpdateAmount allows the owner of the LifetimeSubscription contract to update the subscription price. func (ls *LifetimeSubscription) UpdateAmount(newAmount int64) error { - if err := ls.CallerIsOwner(); err != nil { + if !ls.CallerIsOwner() { return ErrNotAuthorized } diff --git a/examples/gno.land/p/demo/subscription/recurring/recurring.gno b/examples/gno.land/p/demo/subscription/recurring/recurring.gno index b5277bd716e..8f116009aa6 100644 --- a/examples/gno.land/p/demo/subscription/recurring/recurring.gno +++ b/examples/gno.land/p/demo/subscription/recurring/recurring.gno @@ -90,7 +90,7 @@ func (rs *RecurringSubscription) GetExpiration(addr std.Address) (time.Time, err // UpdateAmount allows the owner of the subscription contract to change the required subscription amount. func (rs *RecurringSubscription) UpdateAmount(newAmount int64) error { - if err := rs.CallerIsOwner(); err != nil { + if !rs.CallerIsOwner() { return ErrNotAuthorized } diff --git a/examples/gno.land/p/demo/testutils/crypto_test.gno b/examples/gno.land/p/demo/testutils/crypto_test.gno new file mode 100644 index 00000000000..ac77b76dadf --- /dev/null +++ b/examples/gno.land/p/demo/testutils/crypto_test.gno @@ -0,0 +1,12 @@ +package testutils + +import ( + "testing" + + "gno.land/p/demo/uassert" +) + +func TestTestAddress(t *testing.T) { + testAddr := TestAddress("author1") + uassert.Equal(t, "g1v96hg6r0wgc47h6lta047h6lta047h6lm33tq6", string(testAddr)) +} diff --git a/examples/gno.land/p/moul/addrset/addrset.gno b/examples/gno.land/p/moul/addrset/addrset.gno new file mode 100644 index 00000000000..0bb8165f9fe --- /dev/null +++ b/examples/gno.land/p/moul/addrset/addrset.gno @@ -0,0 +1,100 @@ +// Package addrset provides a specialized set data structure for managing unique Gno addresses. +// +// It is built on top of an AVL tree for efficient operations and maintains addresses in sorted order. +// This package is particularly useful when you need to: +// - Track a collection of unique addresses (e.g., for whitelists, participants, etc.) +// - Efficiently check address membership +// - Support pagination when displaying addresses +// +// Example usage: +// +// import ( +// "std" +// "gno.land/p/moul/addrset" +// ) +// +// func MyHandler() { +// // Create a new address set +// var set addrset.Set +// +// // Add some addresses +// addr1 := std.Address("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5") +// addr2 := std.Address("g1sss5g0rkqr88k4u648yd5d3l9t4d8vvqwszqth") +// +// set.Add(addr1) // returns true (newly added) +// set.Add(addr2) // returns true (newly added) +// set.Add(addr1) // returns false (already exists) +// +// // Check membership +// if set.Has(addr1) { +// // addr1 is in the set +// } +// +// // Get size +// size := set.Size() // returns 2 +// +// // Iterate with pagination (10 items per page, starting at offset 0) +// set.IterateByOffset(0, 10, func(addr std.Address) bool { +// // Process addr +// return false // continue iteration +// }) +// +// // Remove an address +// set.Remove(addr1) // returns true (was present) +// set.Remove(addr1) // returns false (not present) +// } +package addrset + +import ( + "std" + + "gno.land/p/demo/avl" +) + +type Set struct { + tree avl.Tree +} + +// Add inserts an address into the set. +// Returns true if the address was newly added, false if it already existed. +func (s *Set) Add(addr std.Address) bool { + return !s.tree.Set(string(addr), nil) +} + +// Remove deletes an address from the set. +// Returns true if the address was found and removed, false if it didn't exist. +func (s *Set) Remove(addr std.Address) bool { + _, removed := s.tree.Remove(string(addr)) + return removed +} + +// Has checks if an address exists in the set. +func (s *Set) Has(addr std.Address) bool { + return s.tree.Has(string(addr)) +} + +// Size returns the number of addresses in the set. +func (s *Set) Size() int { + return s.tree.Size() +} + +// IterateByOffset walks through addresses starting at the given offset. +// The callback should return true to stop iteration. +func (s *Set) IterateByOffset(offset int, count int, cb func(addr std.Address) bool) { + s.tree.IterateByOffset(offset, count, func(key string, _ interface{}) bool { + return cb(std.Address(key)) + }) +} + +// ReverseIterateByOffset walks through addresses in reverse order starting at the given offset. +// The callback should return true to stop iteration. +func (s *Set) ReverseIterateByOffset(offset int, count int, cb func(addr std.Address) bool) { + s.tree.ReverseIterateByOffset(offset, count, func(key string, _ interface{}) bool { + return cb(std.Address(key)) + }) +} + +// Tree returns the underlying AVL tree for advanced usage. +func (s *Set) Tree() avl.ITree { + return &s.tree +} diff --git a/examples/gno.land/p/moul/addrset/addrset_test.gno b/examples/gno.land/p/moul/addrset/addrset_test.gno new file mode 100644 index 00000000000..c3e27eab1df --- /dev/null +++ b/examples/gno.land/p/moul/addrset/addrset_test.gno @@ -0,0 +1,174 @@ +package addrset + +import ( + "std" + "testing" + + "gno.land/p/demo/uassert" +) + +func TestSet(t *testing.T) { + addr1 := std.Address("addr1") + addr2 := std.Address("addr2") + addr3 := std.Address("addr3") + + tests := []struct { + name string + actions func(s *Set) + size int + has map[std.Address]bool + addrs []std.Address // for iteration checks + }{ + { + name: "empty set", + actions: func(s *Set) {}, + size: 0, + has: map[std.Address]bool{addr1: false}, + }, + { + name: "single address", + actions: func(s *Set) { + s.Add(addr1) + }, + size: 1, + has: map[std.Address]bool{ + addr1: true, + addr2: false, + }, + addrs: []std.Address{addr1}, + }, + { + name: "multiple addresses", + actions: func(s *Set) { + s.Add(addr1) + s.Add(addr2) + s.Add(addr3) + }, + size: 3, + has: map[std.Address]bool{ + addr1: true, + addr2: true, + addr3: true, + }, + addrs: []std.Address{addr1, addr2, addr3}, + }, + { + name: "remove address", + actions: func(s *Set) { + s.Add(addr1) + s.Add(addr2) + s.Remove(addr1) + }, + size: 1, + has: map[std.Address]bool{ + addr1: false, + addr2: true, + }, + addrs: []std.Address{addr2}, + }, + { + name: "duplicate adds", + actions: func(s *Set) { + uassert.True(t, s.Add(addr1)) // first add returns true + uassert.False(t, s.Add(addr1)) // second add returns false + uassert.True(t, s.Remove(addr1)) // remove existing returns true + uassert.False(t, s.Remove(addr1)) // remove non-existing returns false + }, + size: 0, + has: map[std.Address]bool{ + addr1: false, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var set Set + + // Execute test actions + tt.actions(&set) + + // Check size + uassert.Equal(t, tt.size, set.Size()) + + // Check existence + for addr, expected := range tt.has { + uassert.Equal(t, expected, set.Has(addr)) + } + + // Check iteration if addresses are specified + if tt.addrs != nil { + collected := []std.Address{} + set.IterateByOffset(0, 10, func(addr std.Address) bool { + collected = append(collected, addr) + return false + }) + + // Check length + uassert.Equal(t, len(tt.addrs), len(collected)) + + // Check each address + for i, addr := range tt.addrs { + uassert.Equal(t, addr, collected[i]) + } + } + }) + } +} + +func TestSetIterationLimits(t *testing.T) { + tests := []struct { + name string + addrs []std.Address + offset int + limit int + expected int + }{ + { + name: "zero offset full list", + addrs: []std.Address{"a1", "a2", "a3"}, + offset: 0, + limit: 10, + expected: 3, + }, + { + name: "offset with limit", + addrs: []std.Address{"a1", "a2", "a3", "a4"}, + offset: 1, + limit: 2, + expected: 2, + }, + { + name: "offset beyond size", + addrs: []std.Address{"a1", "a2"}, + offset: 3, + limit: 1, + expected: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var set Set + for _, addr := range tt.addrs { + set.Add(addr) + } + + // Test forward iteration + count := 0 + set.IterateByOffset(tt.offset, tt.limit, func(addr std.Address) bool { + count++ + return false + }) + uassert.Equal(t, tt.expected, count) + + // Test reverse iteration + count = 0 + set.ReverseIterateByOffset(tt.offset, tt.limit, func(addr std.Address) bool { + count++ + return false + }) + uassert.Equal(t, tt.expected, count) + }) + } +} diff --git a/examples/gno.land/p/moul/addrset/gno.mod b/examples/gno.land/p/moul/addrset/gno.mod new file mode 100644 index 00000000000..45bb53b399c --- /dev/null +++ b/examples/gno.land/p/moul/addrset/gno.mod @@ -0,0 +1 @@ +module gno.land/p/moul/addrset diff --git a/examples/gno.land/p/moul/collection/collection.gno b/examples/gno.land/p/moul/collection/collection.gno new file mode 100644 index 00000000000..f6d26e6a3ee --- /dev/null +++ b/examples/gno.land/p/moul/collection/collection.gno @@ -0,0 +1,509 @@ +// Package collection provides a generic collection implementation with support for +// multiple indexes, including unique indexes and case-insensitive indexes. +// It is designed to be used with any type and allows efficient lookups using +// different fields or computed values. +// +// Example usage: +// +// // Define a data type +// type User struct { +// Name string +// Email string +// Age int +// Username string +// Tags []string +// } +// +// // Create a new collection +// c := collection.New() +// +// // Add indexes with different options +// c.AddIndex("name", func(v interface{}) string { +// return v.(*User).Name +// }, UniqueIndex) +// +// c.AddIndex("email", func(v interface{}) string { +// return v.(*User).Email +// }, UniqueIndex|CaseInsensitiveIndex) +// +// c.AddIndex("age", func(v interface{}) string { +// return strconv.Itoa(v.(*User).Age) +// }, DefaultIndex) // Non-unique index +// +// c.AddIndex("username", func(v interface{}) string { +// return v.(*User).Username +// }, UniqueIndex|SparseIndex) // Allow empty usernames +// +// // For tags, we index all tags for the user +// c.AddIndex("tag", func(v interface{}) []string { +// return v.(*User).Tags +// }, DefaultIndex) // Non-unique to allow multiple users with same tag +// +// // Store an object +// id := c.Set(&User{ +// Name: "Alice", +// Email: "alice@example.com", +// Age: 30, +// Tags: []string{"admin", "moderator"}, // User can have multiple tags +// }) +// +// // Retrieve by any index +// entry := c.GetFirst("email", "alice@example.com") +// adminUsers := c.GetAll("tag", "admin") // Find all users with admin tag +// modUsers := c.GetAll("tag", "moderator") // Find all users with moderator tag +// +// Index options can be combined using the bitwise OR operator. +// Available options: +// - DefaultIndex: Regular index with no special behavior +// - UniqueIndex: Ensures values are unique within the index +// - CaseInsensitiveIndex: Makes string comparisons case-insensitive +// - SparseIndex: Skips indexing empty values (nil or empty string) +// +// Example: UniqueIndex|CaseInsensitiveIndex for a case-insensitive unique index +package collection + +import ( + "errors" + "strings" + + "gno.land/p/demo/avl" + "gno.land/p/demo/seqid" +) + +// New creates a new Collection instance with an initialized ID index. +// The ID index is a special unique index that is always present and +// serves as the primary key for all objects in the collection. +func New() *Collection { + c := &Collection{ + indexes: make(map[string]*Index), + idGen: seqid.ID(0), + } + // Initialize _id index + c.indexes[IDIndex] = &Index{ + options: UniqueIndex, + tree: avl.NewTree(), + } + return c +} + +// Collection represents a collection of objects with multiple indexes +type Collection struct { + indexes map[string]*Index + idGen seqid.ID +} + +const ( + // IDIndex is the reserved name for the primary key index + IDIndex = "_id" +) + +// IndexOption represents configuration options for an index using bit flags +type IndexOption uint64 + +const ( + // DefaultIndex is a basic index with no special options + DefaultIndex IndexOption = 0 + + // UniqueIndex ensures no duplicate values are allowed + UniqueIndex IndexOption = 1 << iota + + // CaseInsensitiveIndex automatically converts string values to lowercase + CaseInsensitiveIndex + + // SparseIndex only indexes non-empty values + SparseIndex +) + +// Index represents an index with its configuration and data. +// The index function can return either: +// - string: for single-value indexes +// - []string: for multi-value indexes where one object can be indexed under multiple keys +// +// The backing tree stores either a single ID or []string for multiple IDs per key. +type Index struct { + fn interface{} + options IndexOption + tree avl.ITree +} + +// AddIndex adds a new index to the collection with the specified options +// +// Parameters: +// - name: the unique name of the index (e.g., "tags") +// - indexFn: a function that extracts either a string or []string from an object +// - options: bit flags for index configuration (e.g., UniqueIndex) +func (c *Collection) AddIndex(name string, indexFn interface{}, options IndexOption) { + if name == IDIndex { + panic("_id is a reserved index name") + } + c.indexes[name] = &Index{ + fn: indexFn, + options: options, + tree: avl.NewTree(), + } +} + +// storeIndex handles how we store an ID in the index tree +func (idx *Index) store(key string, idStr string) { + stored, exists := idx.tree.Get(key) + if !exists { + // First entry for this key + idx.tree.Set(key, idStr) + return + } + + // Handle existing entries + switch existing := stored.(type) { + case string: + if existing == idStr { + return // Already stored + } + // Convert to array + idx.tree.Set(key, []string{existing, idStr}) + case []string: + // Check if ID already exists + for _, id := range existing { + if id == idStr { + return + } + } + // Append new ID + idx.tree.Set(key, append(existing, idStr)) + } +} + +// removeIndex handles how we remove an ID from the index tree +func (idx *Index) remove(key string, idStr string) { + stored, exists := idx.tree.Get(key) + if !exists { + return + } + + switch existing := stored.(type) { + case string: + if existing == idStr { + idx.tree.Remove(key) + } + case []string: + newIds := make([]string, 0, len(existing)) + for _, id := range existing { + if id != idStr { + newIds = append(newIds, id) + } + } + if len(newIds) == 0 { + idx.tree.Remove(key) + } else if len(newIds) == 1 { + idx.tree.Set(key, newIds[0]) + } else { + idx.tree.Set(key, newIds) + } + } +} + +// generateKeys extracts one or more keys from an object for a given index. +func generateKeys(idx *Index, obj interface{}) ([]string, bool) { + if obj == nil { + return nil, false + } + + switch fnTyped := idx.fn.(type) { + case func(interface{}) string: + // Single-value index + key := fnTyped(obj) + return []string{key}, true + case func(interface{}) []string: + // Multi-value index + keys := fnTyped(obj) + return keys, true + default: + panic("invalid index function type") + } +} + +// Set adds or updates an object in the collection. +// Returns a positive ID if successful. +// Returns 0 if: +// - The object is nil +// - A uniqueness constraint would be violated +// - Index generation fails for any index +func (c *Collection) Set(obj interface{}) uint64 { + if obj == nil { + return 0 + } + + // Generate new ID + id := c.idGen.Next() + idStr := id.String() + + // Check uniqueness constraints first + for name, idx := range c.indexes { + if name == IDIndex { + continue + } + keys, ok := generateKeys(idx, obj) + if !ok { + return 0 + } + + for _, key := range keys { + // Skip empty values for sparse indexes + if idx.options&SparseIndex != 0 && key == "" { + continue + } + if idx.options&CaseInsensitiveIndex != 0 { + key = strings.ToLower(key) + } + // Only check uniqueness for unique + single-value indexes + // (UniqueIndex is ambiguous; skipping that scenario) + if idx.options&UniqueIndex != 0 { + if existing, exists := idx.tree.Get(key); exists && existing != nil { + return 0 + } + } + } + } + + // Store in _id index first (the actual object) + c.indexes[IDIndex].tree.Set(idStr, obj) + + // Store in all other indexes + for name, idx := range c.indexes { + if name == IDIndex { + continue + } + keys, ok := generateKeys(idx, obj) + if !ok { + // Rollback: remove from _id index + c.indexes[IDIndex].tree.Remove(idStr) + return 0 + } + + for _, key := range keys { + if idx.options&SparseIndex != 0 && key == "" { + continue + } + if idx.options&CaseInsensitiveIndex != 0 { + key = strings.ToLower(key) + } + idx.store(key, idStr) + } + } + + return uint64(id) +} + +// Get retrieves entries matching the given key in the specified index. +// Returns an iterator over the matching entries. +func (c *Collection) Get(indexName string, key string) EntryIterator { + idx, exists := c.indexes[indexName] + if !exists { + return EntryIterator{err: errors.New("index not found: " + indexName)} + } + + if idx.options&CaseInsensitiveIndex != 0 { + key = strings.ToLower(key) + } + + if indexName == IDIndex { + // For ID index, validate the ID format first + _, err := seqid.FromString(key) + if err != nil { + return EntryIterator{err: err} + } + } + + return EntryIterator{ + collection: c, + indexName: indexName, + key: key, + } +} + +// GetFirst returns the first matching entry or nil if none found +func (c *Collection) GetFirst(indexName, key string) *Entry { + iter := c.Get(indexName, key) + if iter.Next() { + return iter.Value() + } + return nil +} + +// Delete removes an object by its ID and returns true if something was deleted +func (c *Collection) Delete(id uint64) bool { + idStr := seqid.ID(id).String() + + // Get the object first to clean up other indexes + obj, exists := c.indexes[IDIndex].tree.Get(idStr) + if !exists { + return false + } + + // Remove from all indexes + for name, idx := range c.indexes { + if name == IDIndex { + idx.tree.Remove(idStr) + continue + } + keys, ok := generateKeys(idx, obj) + if !ok { + continue + } + for _, key := range keys { + if idx.options&CaseInsensitiveIndex != 0 { + key = strings.ToLower(key) + } + idx.remove(key, idStr) + } + } + return true +} + +// Update updates an existing object and returns true if successful +// Returns true if the update was successful. +// Returns false if: +// - The object is nil +// - The ID doesn't exist +// - A uniqueness constraint would be violated +// - Index generation fails for any index +// +// If the update fails, the collection remains unchanged. +func (c *Collection) Update(id uint64, obj interface{}) bool { + if obj == nil { + return false + } + idStr := seqid.ID(id).String() + oldObj, exists := c.indexes[IDIndex].tree.Get(idStr) + if !exists { + return false + } + + // Check unique constraints + for name, idx := range c.indexes { + if name == IDIndex { + continue + } + + if idx.options&UniqueIndex != 0 { + newKeys, newOk := generateKeys(idx, obj) + _, oldOk := generateKeys(idx, oldObj) + if !newOk || !oldOk { + return false + } + + for _, newKey := range newKeys { + if idx.options&CaseInsensitiveIndex != 0 { + newKey = strings.ToLower(newKey) + } + + found, _ := idx.tree.Get(newKey) + if found != nil { + if storedID, ok := found.(string); !ok || storedID != idStr { + return false + } + } + } + } + } + + // Store old index entries for potential rollback + oldEntries := make(map[string][]string) + for name, idx := range c.indexes { + if name == IDIndex { + continue + } + oldKeys, ok := generateKeys(idx, oldObj) + if !ok { + continue + } + var adjusted []string + for _, okey := range oldKeys { + if idx.options&CaseInsensitiveIndex != 0 { + okey = strings.ToLower(okey) + } + // Remove the oldObj from the index right away + idx.remove(okey, idStr) + adjusted = append(adjusted, okey) + } + oldEntries[name] = adjusted + } + + // Update the object in the _id index + c.indexes[IDIndex].tree.Set(idStr, obj) + + // Add new index entries + for name, idx := range c.indexes { + if name == IDIndex { + continue + } + newKeys, ok := generateKeys(idx, obj) + if !ok { + // Rollback: restore old object and old index entries + c.indexes[IDIndex].tree.Set(idStr, oldObj) + for idxName, keys := range oldEntries { + for _, oldKey := range keys { + c.indexes[idxName].store(oldKey, idStr) + } + } + return false + } + for _, nkey := range newKeys { + if idx.options&CaseInsensitiveIndex != 0 { + nkey = strings.ToLower(nkey) + } + idx.store(nkey, idStr) + } + } + + return true +} + +// GetAll retrieves all entries matching the given key in the specified index. +func (c *Collection) GetAll(indexName string, key string) []Entry { + idx, exists := c.indexes[indexName] + if !exists { + return nil + } + + if idx.options&CaseInsensitiveIndex != 0 { + key = strings.ToLower(key) + } + + if indexName == IDIndex { + if obj, exists := idx.tree.Get(key); exists { + return []Entry{{ID: key, Obj: obj}} + } + return nil + } + + idData, exists := idx.tree.Get(key) + if !exists { + return nil + } + + // Handle both single and multi-value cases based on the actual data type + switch stored := idData.(type) { + case []string: + result := make([]Entry, 0, len(stored)) + for _, idStr := range stored { + if obj, exists := c.indexes[IDIndex].tree.Get(idStr); exists { + result = append(result, Entry{ID: idStr, Obj: obj}) + } + } + return result + case string: + if obj, exists := c.indexes[IDIndex].tree.Get(stored); exists { + return []Entry{{ID: stored, Obj: obj}} + } + } + return nil +} + +// GetIndex returns the underlying tree for an index +func (c *Collection) GetIndex(name string) avl.ITree { + idx, exists := c.indexes[name] + if !exists { + return nil + } + return idx.tree +} diff --git a/examples/gno.land/p/moul/collection/collection_test.gno b/examples/gno.land/p/moul/collection/collection_test.gno new file mode 100644 index 00000000000..3e03d222ce8 --- /dev/null +++ b/examples/gno.land/p/moul/collection/collection_test.gno @@ -0,0 +1,987 @@ +package collection + +import ( + "errors" + "strconv" + "strings" + "testing" + + "gno.land/p/demo/seqid" + "gno.land/p/demo/ufmt" +) + +type Person struct { + Name string + Age int + Email string + Username string + Tags []string +} + +func (p Person) String() string { + return ufmt.Sprintf("name=%s age=%d email=%s username=%s tags=%s", + p.Name, p.Age, p.Email, p.Username, strings.Join(p.Tags, ",")) +} + +// TestOperation represents a single operation in a test sequence +type TestOperation struct { + op string // "set" or "update" + person *Person + id uint64 // for updates + wantID uint64 + wantErr bool +} + +// TestCase represents a complete test case with setup and operations +type TestCase struct { + name string + setupIndex func(*Collection) + operations []TestOperation +} + +func TestBasicOperations(t *testing.T) { + c := New() + + // Add indexes + c.AddIndex("name", func(v interface{}) string { + return v.(*Person).Name + }, UniqueIndex) + c.AddIndex("age", func(v interface{}) string { + return strconv.Itoa(v.(*Person).Age) + }, DefaultIndex) + + // Test basic Set and Get + p1 := &Person{Name: "Alice", Age: 30, Email: "alice@test.com"} + id1 := c.Set(p1) + if id1 == 0 { + t.Error("Failed to set first object") + } + + // Get by ID + iter := c.Get(IDIndex, seqid.ID(id1).String()) + if !iter.Next() { + t.Error("Failed to get object by ID") + } + entry := iter.Value() + if entry.Obj.(*Person).Name != "Alice" { + t.Error("Got wrong object") + } +} + +func TestUniqueConstraints(t *testing.T) { + tests := []struct { + name string + setup func(*Collection) uint64 + wantID bool + }{ + { + name: "First person", + setup: func(c *Collection) uint64 { + return c.Set(&Person{Name: "Alice"}) + }, + wantID: true, + }, + { + name: "Duplicate name", + setup: func(c *Collection) uint64 { + c.Set(&Person{Name: "Alice"}) + return c.Set(&Person{Name: "Alice"}) + }, + wantID: false, + }, + { + name: "Same age (non-unique index)", + setup: func(c *Collection) uint64 { + c.AddIndex("age", func(v interface{}) string { + return strconv.Itoa(v.(*Person).Age) + }, DefaultIndex) + c.Set(&Person{Name: "Alice", Age: 30}) + return c.Set(&Person{Name: "Bob", Age: 30}) + }, + wantID: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := New() + c.AddIndex("name", func(v interface{}) string { + return v.(*Person).Name + }, UniqueIndex) + + id := tt.setup(c) + if (id != 0) != tt.wantID { + t.Errorf("Set() got id = %v, want non-zero: %v", id, tt.wantID) + } + }) + } +} + +func TestUpdates(t *testing.T) { + c := New() + c.AddIndex("name", func(v interface{}) string { + return v.(*Person).Name + }, UniqueIndex) + c.AddIndex("username", func(v interface{}) string { + return strings.ToLower(v.(*Person).Username) + }, UniqueIndex|CaseInsensitiveIndex) + + // Initial setup + p1 := &Person{Name: "Alice", Username: "alice123"} + p2 := &Person{Name: "Bob", Username: "bob456"} + + id1 := c.Set(p1) + id2 := c.Set(p2) + + tests := []struct { + name string + id uint64 + newPerson *Person + wantRet bool + }{ + { + name: "Update to non-conflicting values", + id: id1, + newPerson: &Person{Name: "Alice2", Username: "alice1234"}, + wantRet: true, + }, + { + name: "Update to conflicting username", + id: id1, + newPerson: &Person{Name: "Alice2", Username: "bob456"}, + wantRet: false, + }, + { + name: "Update non-existent ID", + id: 99999, + newPerson: &Person{Name: "Test", Username: "test"}, + wantRet: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotID := c.Update(tt.id, tt.newPerson) + if gotID != tt.wantRet { + t.Errorf("Update() got = %v, want %v", gotID, tt.wantRet) + } + }) + } +} + +func TestDelete(t *testing.T) { + c := New() + c.AddIndex("name", func(v interface{}) string { + return v.(*Person).Name + }, UniqueIndex) + + p1 := &Person{Name: "Alice"} + id1 := c.Set(p1) + + tests := []struct { + name string + id uint64 + wantRet bool + }{ + { + name: "Delete existing object", + id: id1, + wantRet: true, + }, + { + name: "Delete non-existent object", + id: 99999, + wantRet: false, + }, + { + name: "Delete already deleted object", + id: id1, + wantRet: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotID := c.Delete(tt.id) + if gotID != tt.wantRet { + t.Errorf("Delete() got = %v, want %v", gotID, tt.wantRet) + } + }) + } +} + +func TestEdgeCases(t *testing.T) { + c := New() + c.AddIndex("name", func(v interface{}) string { + return v.(*Person).Name + }, UniqueIndex) + + tests := []struct { + name string + operation func() bool + wantPanic bool + }{ + { + name: "Set nil object", + operation: func() bool { + return c.Set(nil) != 0 + }, + wantPanic: false, + }, + { + name: "Set wrong type", + operation: func() bool { + return c.Set("not a person") != 0 + }, + wantPanic: true, + }, + { + name: "Update with nil", + operation: func() bool { + id := c.Set(&Person{Name: "Test"}) + return c.Update(id, nil) + }, + wantPanic: false, + }, + { + name: "Get with invalid index name", + operation: func() bool { + iter := c.Get("invalid_index", "key") + if iter.Empty() { + return false + } + entry := iter.Value() + if entry == nil { + return false + } + id, err := seqid.FromString(entry.ID) + if err != nil { + return false + } + return true + }, + wantPanic: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var got bool + panicked := false + + func() { + defer func() { + if r := recover(); r != nil { + panicked = true + } + }() + got = tt.operation() + }() + + if panicked != tt.wantPanic { + t.Errorf("Operation panicked = %v, want panic = %v", panicked, tt.wantPanic) + } + if !panicked && got != false { + t.Errorf("Operation returned %v, want 0", got) + } + }) + } +} + +func TestIndexTypes(t *testing.T) { + c := New() + + // Test different types of indexes + c.AddIndex("composite", func(v interface{}) string { + p := v.(*Person) + return p.Name + ":" + strconv.Itoa(p.Age) + }, UniqueIndex) + + c.AddIndex("case_insensitive", func(v interface{}) string { + return strings.ToLower(v.(*Person).Username) + }, UniqueIndex|CaseInsensitiveIndex) + + // Test composite index + p1 := &Person{Name: "Alice", Age: 30, Username: "Alice123"} + id1 := c.Set(p1) + if id1 == 0 { + t.Error("Failed to set object with composite index") + } + + // Test case-insensitive index + p2 := &Person{Name: "Bob", Age: 25, Username: "alice123"} + id2 := c.Set(p2) + if id2 != 0 { + t.Error("Case-insensitive index failed to prevent duplicate") + } +} + +func TestIndexOptions(t *testing.T) { + tests := []struct { + name string + setup func(*Collection) uint64 + wantID bool + wantErr bool + }{ + { + name: "Unique case-sensitive index", + setup: func(c *Collection) uint64 { + c.AddIndex("username", func(v interface{}) string { + return v.(*Person).Username + }, UniqueIndex) + + id1 := c.Set(&Person{Username: "Alice"}) + return c.Set(&Person{Username: "Alice"}) // Should fail + }, + wantID: false, + }, + { + name: "Unique case-insensitive index", + setup: func(c *Collection) uint64 { + c.AddIndex("email", func(v interface{}) string { + return v.(*Person).Email + }, UniqueIndex|CaseInsensitiveIndex) + + id1 := c.Set(&Person{Email: "test@example.com"}) + return c.Set(&Person{Email: "TEST@EXAMPLE.COM"}) // Should fail + }, + wantID: false, + }, + { + name: "Default index", + setup: func(c *Collection) uint64 { + c.AddIndex("age", func(v interface{}) string { + return strconv.Itoa(v.(*Person).Age) + }, DefaultIndex) + + // First person with age 30 + id1 := c.Set(&Person{Age: 30}) + if id1 == 0 { + t.Error("Failed to set first person") + } + + // Second person with same age should succeed + return c.Set(&Person{Age: 30}) + }, + wantID: true, + }, + { + name: "Multiple options", + setup: func(c *Collection) uint64 { + c.AddIndex("name", func(v interface{}) string { + return v.(*Person).Name + }, UniqueIndex|CaseInsensitiveIndex|SparseIndex) + + id1 := c.Set(&Person{Name: "Alice"}) + return c.Set(&Person{Name: "ALICE"}) // Should fail + }, + wantID: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := New() // Create new collection for each test + id := tt.setup(c) + if (id != 0) != tt.wantID { + t.Errorf("got id = %v, want non-zero: %v", id, tt.wantID) + } + }) + } +} + +func TestConcurrentOperations(t *testing.T) { + c := New() + c.AddIndex("name", func(v interface{}) string { + return v.(*Person).Name + }, UniqueIndex) + + p1 := &Person{Name: "Alice"} + id1 := c.Set(p1) + iter := c.Get("_id", seqid.ID(id1).String()) + success := c.Update(id1, &Person{Name: "Alice2"}) + + if iter.Empty() || !success { + t.Error("Concurrent operations failed") + } +} + +func TestSparseIndexBehavior(t *testing.T) { + c := New() + c.AddIndex("optional_field", func(v interface{}) string { + return v.(*Person).Username + }, SparseIndex) + + tests := []struct { + name string + person *Person + wantID bool + }{ + { + name: "Empty optional field", + person: &Person{Name: "Alice", Email: "alice@test.com"}, + wantID: true, + }, + { + name: "Populated optional field", + person: &Person{Name: "Bob", Email: "bob@test.com", Username: "bobby"}, + wantID: true, + }, + { + name: "Multiple empty fields", + person: &Person{Name: "Charlie"}, + wantID: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + id := c.Set(tt.person) + if (id != 0) != tt.wantID { + t.Errorf("Set() got id = %v, want non-zero: %v", id, tt.wantID) + } + }) + } +} + +func TestIndexKeyGeneration(t *testing.T) { + c := New() + c.AddIndex("composite", func(v interface{}) string { + p := v.(*Person) + return p.Name + ":" + strconv.Itoa(p.Age) + }, UniqueIndex) + + tests := []struct { + name string + person *Person + wantID bool + }{ + { + name: "Valid composite key", + person: &Person{Name: "Alice", Age: 30}, + wantID: true, + }, + { + name: "Duplicate composite key", + person: &Person{Name: "Alice", Age: 30}, + wantID: false, + }, + { + name: "Different composite key", + person: &Person{Name: "Alice", Age: 31}, + wantID: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + id := c.Set(tt.person) + if (id != 0) != tt.wantID { + t.Errorf("Set() got id = %v, want non-zero: %v", id, tt.wantID) + } + }) + } +} + +func TestGetIndex(t *testing.T) { + c := New() + c.AddIndex("name", func(v interface{}) string { + return v.(*Person).Name + }, UniqueIndex) + + tests := []struct { + name string + indexName string + wantNil bool + }{ + { + name: "Get existing index", + indexName: "name", + wantNil: false, + }, + { + name: "Get _id index", + indexName: IDIndex, + wantNil: false, + }, + { + name: "Get non-existent index", + indexName: "invalid", + wantNil: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tree := c.GetIndex(tt.indexName) + if (tree == nil) != tt.wantNil { + t.Errorf("GetIndex() got nil = %v, want nil = %v", tree == nil, tt.wantNil) + } + }) + } +} + +func TestAddIndexPanic(t *testing.T) { + c := New() + defer func() { + if r := recover(); r == nil { + t.Error("Expected panic when adding _id index") + } + }() + + c.AddIndex(IDIndex, func(v interface{}) string { + return "" + }, DefaultIndex) +} + +func TestCaseInsensitiveOperations(t *testing.T) { + c := New() + c.AddIndex("email", func(v interface{}) string { + return v.(*Person).Email + }, UniqueIndex|CaseInsensitiveIndex) + + p := &Person{Email: "Test@Example.com"} + id := c.Set(p) + + tests := []struct { + name string + key string + wantObj bool + operation string // "get" or "getAll" + wantCount int + }{ + {"Get exact match", "Test@Example.com", true, "get", 1}, + {"Get different case", "test@example.COM", true, "get", 1}, + {"Get non-existent", "other@example.com", false, "get", 0}, + {"GetAll exact match", "Test@Example.com", true, "getAll", 1}, + {"GetAll different case", "test@example.COM", true, "getAll", 1}, + {"GetAll non-existent", "other@example.com", false, "getAll", 0}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.operation == "get" { + iter := c.Get("email", tt.key) + if iter.Empty() { + if tt.wantObj { + t.Error("Expected iterator to not be empty") + } + return + } + hasValue := iter.Next() + if hasValue != tt.wantObj { + t.Errorf("Get() got object = %v, want object = %v", hasValue, tt.wantObj) + } + if hasValue { + entry := iter.Value() + if entry.ID != seqid.ID(id).String() { + t.Errorf("Get() got id = %v, want id = %v", entry.ID, seqid.ID(id).String()) + } + } + } else { + entries := c.GetAll("email", tt.key) + if len(entries) != tt.wantCount { + t.Errorf("GetAll() returned %d entries, want %d", len(entries), tt.wantCount) + } + if tt.wantCount > 0 { + entry := entries[0] + if entry.ID != seqid.ID(id).String() { + t.Errorf("GetAll() returned ID %s, want %s", entry.ID, seqid.ID(id).String()) + } + } + } + }) + } +} + +func TestGetInvalidID(t *testing.T) { + c := New() + iter := c.Get(IDIndex, "not-a-valid-id") + if !iter.Empty() { + t.Errorf("Get() with invalid ID format got an entry, want nil") + } +} + +func TestGetAll(t *testing.T) { + c := New() + c.AddIndex("tags", func(v interface{}) []string { + return v.(*Person).Tags + }, DefaultIndex) + c.AddIndex("age", func(v interface{}) string { + return strconv.Itoa(v.(*Person).Age) + }, DefaultIndex) + c.AddIndex("name", func(v interface{}) string { + return v.(*Person).Name + }, UniqueIndex) + + // Create test data + people := []*Person{ + {Name: "Alice", Age: 30, Tags: []string{"dev", "go"}}, + {Name: "Bob", Age: 30, Tags: []string{"dev", "python"}}, + {Name: "Charlie", Age: 25, Tags: []string{"dev", "rust"}}, + } + + ids := make([]uint64, len(people)) + for i, p := range people { + ids[i] = c.Set(p) + if ids[i] == 0 { + t.Fatalf("Failed to set person %s", p.Name) + } + } + + tests := []struct { + name string + indexName string + key string + wantCount int + }{ + { + name: "Multi-value index with multiple matches", + indexName: "tags", + key: "dev", + wantCount: 3, + }, + { + name: "Multi-value index with single match", + indexName: "tags", + key: "go", + wantCount: 1, + }, + { + name: "Multi-value index with no matches", + indexName: "tags", + key: "java", + wantCount: 0, + }, + { + name: "Single-value non-unique index with multiple matches", + indexName: "age", + key: "30", + wantCount: 2, + }, + { + name: "Single-value unique index", + indexName: "name", + key: "Alice", + wantCount: 1, + }, + { + name: "Non-existent index", + indexName: "invalid", + key: "value", + wantCount: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + iter := c.Get(tt.indexName, tt.key) + count := 0 + for iter.Next() { + entry := iter.Value() + if entry.ID == "" { + t.Error("Got entry with empty ID") + } + if entry.Obj == nil { + t.Error("Got entry with nil Obj") + } + count++ + } + if count != tt.wantCount { + t.Errorf("Got %d entries, want %d", count, tt.wantCount) + } + }) + } +} + +func TestIndexOperations(t *testing.T) { + tests := []struct { + name string + setup func(*Collection) (uint64, error) + verify func(*Collection, uint64) error + wantErr bool + }{ + { + name: "Basic set and get", + setup: func(c *Collection) (uint64, error) { + c.AddIndex("name", func(v interface{}) string { + return v.(*Person).Name + }, UniqueIndex) + return c.Set(&Person{Name: "Alice", Age: 30}), nil + }, + verify: func(c *Collection, id uint64) error { + iter := c.Get(IDIndex, seqid.ID(id).String()) + if !iter.Next() { + return errors.New("failed to get object by ID") + } + entry := iter.Value() + if entry.Obj.(*Person).Name != "Alice" { + return errors.New("got wrong object") + } + return nil + }, + }, + { + name: "Composite index", + setup: func(c *Collection) (uint64, error) { + c.AddIndex("composite", func(v interface{}) string { + p := v.(*Person) + return p.Name + ":" + strconv.Itoa(p.Age) + }, UniqueIndex) + return c.Set(&Person{Name: "Alice", Age: 30}), nil + }, + verify: func(c *Collection, id uint64) error { + iter := c.Get("composite", "Alice:30") + if !iter.Next() { + return errors.New("failed to get object by composite index") + } + return nil + }, + }, + // Add more test cases combining unique scenarios from original tests + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := New() + id, err := tt.setup(c) + if (err != nil) != tt.wantErr { + t.Errorf("setup error = %v, wantErr %v", err, tt.wantErr) + return + } + if err == nil { + if err := tt.verify(c, id); err != nil { + t.Errorf("verification failed: %v", err) + } + } + }) + } +} + +func TestMultiValueIndexes(t *testing.T) { + c := New() + c.AddIndex("tags", func(v interface{}) []string { + return v.(*Person).Tags + }, DefaultIndex) + + tests := []struct { + name string + setup []*Person + searchTag string + wantCount int + }{ + { + name: "Multiple tags, multiple matches", + setup: []*Person{ + {Name: "Alice", Tags: []string{"dev", "go"}}, + {Name: "Bob", Tags: []string{"dev", "python"}}, + {Name: "Charlie", Tags: []string{"dev", "rust"}}, + }, + searchTag: "dev", + wantCount: 3, + }, + { + name: "Single tag match", + setup: []*Person{ + {Name: "Alice", Tags: []string{"dev", "go"}}, + {Name: "Bob", Tags: []string{"dev", "python"}}, + }, + searchTag: "go", + wantCount: 1, + }, + { + name: "No matches", + setup: []*Person{ + {Name: "Alice", Tags: []string{"dev", "go"}}, + {Name: "Bob", Tags: []string{"dev", "python"}}, + }, + searchTag: "java", + wantCount: 0, + }, + { + name: "Empty tags", + setup: []*Person{ + {Name: "Alice", Tags: []string{}}, + {Name: "Bob", Tags: nil}, + }, + searchTag: "dev", + wantCount: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := New() + c.AddIndex("tags", func(v interface{}) []string { + return v.(*Person).Tags + }, DefaultIndex) + + // Setup test data + for _, p := range tt.setup { + if id := c.Set(p); id == 0 { + t.Fatalf("Failed to set person %s", p.Name) + } + } + + // Test Get operation + iter := c.Get("tags", tt.searchTag) + count := 0 + for iter.Next() { + count++ + } + if count != tt.wantCount { + t.Errorf("Get() got %d matches, want %d", count, tt.wantCount) + } + }) + } +} + +func TestGetOperations(t *testing.T) { + c := New() + c.AddIndex("name", func(v interface{}) string { + return v.(*Person).Name + }, UniqueIndex) + c.AddIndex("age", func(v interface{}) string { + return strconv.Itoa(v.(*Person).Age) + }, DefaultIndex) + + // Setup test data + testPeople := []*Person{ + {Name: "Alice", Age: 30}, + {Name: "Bob", Age: 30}, + {Name: "Charlie", Age: 25}, + } + + ids := make([]uint64, len(testPeople)) + for i, p := range testPeople { + ids[i] = c.Set(p) + if ids[i] == 0 { + t.Fatalf("Failed to set person %s", p.Name) + } + } + + tests := []struct { + name string + indexName string + key string + wantCount int + wantErr bool + }{ + { + name: "Get by ID", + indexName: IDIndex, + key: seqid.ID(ids[0]).String(), + wantCount: 1, + wantErr: false, + }, + { + name: "Get by unique index", + indexName: "name", + key: "Alice", + wantCount: 1, + wantErr: false, + }, + { + name: "Get by non-unique index", + indexName: "age", + key: "30", + wantCount: 2, + wantErr: false, + }, + { + name: "Get with invalid index", + indexName: "invalid_index", + key: "value", + wantCount: 0, + wantErr: true, + }, + { + name: "Get with invalid ID format", + indexName: IDIndex, + key: "not-a-valid-id", + wantCount: 0, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + iter := c.Get(tt.indexName, tt.key) + if iter.Empty() { + if !tt.wantErr { + t.Errorf("Get() returned empty iterator, wanted %d results", tt.wantCount) + } + return + } + + count := 0 + for iter.Next() { + entry := iter.Value() + if entry.ID == "" { + t.Error("Got entry with empty ID") + } + if entry.Obj == nil { + t.Error("Got entry with nil Obj") + } + count++ + } + + if count != tt.wantCount { + t.Errorf("Get() returned %d results, want %d", count, tt.wantCount) + } + }) + } +} + +func TestEntryString(t *testing.T) { + tests := []struct { + name string + entry *Entry + expected string + }{ + { + name: "Nil entry", + entry: nil, + expected: "", + }, + { + name: "Person entry", + entry: &Entry{ + ID: "123", + Obj: &Person{Name: "Alice", Age: 30}, + }, + expected: `Entry{ID: 123, Obj: name=Alice age=30 email= username= tags=}`, + }, + { + name: "Entry with nil object", + entry: &Entry{ + ID: "456", + Obj: nil, + }, + expected: `Entry{ID: 456, Obj: }`, + }, + { + name: "Entry with complete person", + entry: &Entry{ + ID: "789", + Obj: &Person{ + Name: "Bob", + Age: 25, + Email: "bob@example.com", + Username: "bobby", + Tags: []string{"dev", "go"}, + }, + }, + expected: `Entry{ID: 789, Obj: name=Bob age=25 email=bob@example.com username=bobby tags=dev,go}`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := tt.entry.String() + if got != tt.expected { + t.Errorf("Entry.String() = %q, want %q", got, tt.expected) + } + }) + } +} diff --git a/examples/gno.land/p/moul/collection/entry.gno b/examples/gno.land/p/moul/collection/entry.gno new file mode 100644 index 00000000000..8daa893b61d --- /dev/null +++ b/examples/gno.land/p/moul/collection/entry.gno @@ -0,0 +1,149 @@ +package collection + +import "gno.land/p/demo/ufmt" + +// Entry represents a single object in the collection with its ID +type Entry struct { + ID string + Obj interface{} +} + +// String returns a string representation of the Entry +func (e *Entry) String() string { + if e == nil { + return "" + } + return ufmt.Sprintf("Entry{ID: %s, Obj: %v}", e.ID, e.Obj) +} + +// EntryIterator provides iteration over collection entries +type EntryIterator struct { + collection *Collection + indexName string + key string + currentID string + currentObj interface{} + err error + closed bool + + // For multi-value cases + ids []string + currentIdx int +} + +func (ei *EntryIterator) Close() error { + ei.closed = true + ei.currentID = "" + ei.currentObj = nil + ei.ids = nil + return nil +} + +func (ei *EntryIterator) Next() bool { + if ei == nil || ei.closed || ei.err != nil { + return false + } + + // Handle ID index specially + if ei.indexName == IDIndex { + if ei.currentID != "" { // We've already returned the single value + return false + } + obj, exists := ei.collection.indexes[IDIndex].tree.Get(ei.key) + if !exists { + return false + } + ei.currentID = ei.key + ei.currentObj = obj + return true + } + + // Get the index + idx, exists := ei.collection.indexes[ei.indexName] + if !exists { + return false + } + + // Initialize ids slice if needed + if ei.ids == nil { + idData, exists := idx.tree.Get(ei.key) + if !exists { + return false + } + + switch stored := idData.(type) { + case []string: + ei.ids = stored + ei.currentIdx = -1 + case string: + ei.ids = []string{stored} + ei.currentIdx = -1 + default: + return false + } + } + + // Move to next ID + ei.currentIdx++ + if ei.currentIdx >= len(ei.ids) { + return false + } + + // Fetch the actual object + ei.currentID = ei.ids[ei.currentIdx] + obj, exists := ei.collection.indexes[IDIndex].tree.Get(ei.currentID) + if !exists { + // Skip invalid entries + return ei.Next() + } + ei.currentObj = obj + return true +} + +func (ei *EntryIterator) Error() error { + return ei.err +} + +func (ei *EntryIterator) Value() *Entry { + if ei == nil || ei.closed || ei.currentID == "" { + return nil + } + return &Entry{ + ID: ei.currentID, + Obj: ei.currentObj, + } +} + +func (ei *EntryIterator) Empty() bool { + if ei == nil || ei.closed || ei.err != nil { + return true + } + + // Handle ID index specially + if ei.indexName == IDIndex { + _, exists := ei.collection.indexes[IDIndex].tree.Get(ei.key) + return !exists + } + + // Get the index + idx, exists := ei.collection.indexes[ei.indexName] + if !exists { + return true + } + + // Check if key exists in index + idData, exists := idx.tree.Get(ei.key) + if !exists { + return true + } + + // Check if there are any valid IDs + switch stored := idData.(type) { + case []string: + return len(stored) == 0 + case string: + return stored == "" + default: + return true + } +} diff --git a/examples/gno.land/p/moul/collection/gno.mod b/examples/gno.land/p/moul/collection/gno.mod new file mode 100644 index 00000000000..a6eeca36837 --- /dev/null +++ b/examples/gno.land/p/moul/collection/gno.mod @@ -0,0 +1 @@ +module gno.land/p/moul/collection diff --git a/examples/gno.land/p/moul/fp/fp.gno b/examples/gno.land/p/moul/fp/fp.gno new file mode 100644 index 00000000000..b2811c77d5a --- /dev/null +++ b/examples/gno.land/p/moul/fp/fp.gno @@ -0,0 +1,270 @@ +// Package fp provides functional programming utilities for Gno, enabling +// transformations, filtering, and other operations on slices of interface{}. +// +// Example of chaining operations: +// +// numbers := []interface{}{1, 2, 3, 4, 5, 6} +// +// // Define predicates, mappers and reducers +// isEven := func(v interface{}) bool { return v.(int)%2 == 0 } +// double := func(v interface{}) interface{} { return v.(int) * 2 } +// sum := func(a, b interface{}) interface{} { return a.(int) + b.(int) } +// +// // Chain operations: filter even numbers, double them, then sum +// evenNums := Filter(numbers, isEven) // [2, 4, 6] +// doubled := Map(evenNums, double) // [4, 8, 12] +// result := Reduce(doubled, sum, 0) // 24 +// +// // Alternative: group by even/odd, then get even numbers +// byMod2 := func(v interface{}) interface{} { return v.(int) % 2 } +// grouped := GroupBy(numbers, byMod2) // {0: [2,4,6], 1: [1,3,5]} +// evens := grouped[0] // [2,4,6] +package fp + +// Mapper is a function type that maps an element to another element. +type Mapper func(interface{}) interface{} + +// Predicate is a function type that evaluates a condition on an element. +type Predicate func(interface{}) bool + +// Reducer is a function type that reduces two elements to a single value. +type Reducer func(interface{}, interface{}) interface{} + +// Filter filters elements from the slice that satisfy the given predicate. +// +// Example: +// +// numbers := []interface{}{-1, 0, 1, 2} +// isPositive := func(v interface{}) bool { return v.(int) > 0 } +// result := Filter(numbers, isPositive) // [1, 2] +func Filter(values []interface{}, fn Predicate) []interface{} { + result := []interface{}{} + for _, v := range values { + if fn(v) { + result = append(result, v) + } + } + return result +} + +// Map applies a function to each element in the slice. +// +// Example: +// +// numbers := []interface{}{1, 2, 3} +// toString := func(v interface{}) interface{} { return fmt.Sprintf("%d", v) } +// result := Map(numbers, toString) // ["1", "2", "3"] +func Map(values []interface{}, fn Mapper) []interface{} { + result := make([]interface{}, len(values)) + for i, v := range values { + result[i] = fn(v) + } + return result +} + +// Reduce reduces a slice to a single value by applying a function. +// +// Example: +// +// numbers := []interface{}{1, 2, 3, 4} +// sum := func(a, b interface{}) interface{} { return a.(int) + b.(int) } +// result := Reduce(numbers, sum, 0) // 10 +func Reduce(values []interface{}, fn Reducer, initial interface{}) interface{} { + acc := initial + for _, v := range values { + acc = fn(acc, v) + } + return acc +} + +// FlatMap maps each element to a collection and flattens the results. +// +// Example: +// +// words := []interface{}{"hello", "world"} +// split := func(v interface{}) interface{} { +// chars := []interface{}{} +// for _, c := range v.(string) { +// chars = append(chars, string(c)) +// } +// return chars +// } +// result := FlatMap(words, split) // ["h","e","l","l","o","w","o","r","l","d"] +func FlatMap(values []interface{}, fn Mapper) []interface{} { + result := []interface{}{} + for _, v := range values { + inner := fn(v).([]interface{}) + result = append(result, inner...) + } + return result +} + +// All returns true if all elements satisfy the predicate. +// +// Example: +// +// numbers := []interface{}{2, 4, 6, 8} +// isEven := func(v interface{}) bool { return v.(int)%2 == 0 } +// result := All(numbers, isEven) // true +func All(values []interface{}, fn Predicate) bool { + for _, v := range values { + if !fn(v) { + return false + } + } + return true +} + +// Any returns true if at least one element satisfies the predicate. +// +// Example: +// +// numbers := []interface{}{1, 3, 4, 7} +// isEven := func(v interface{}) bool { return v.(int)%2 == 0 } +// result := Any(numbers, isEven) // true (4 is even) +func Any(values []interface{}, fn Predicate) bool { + for _, v := range values { + if fn(v) { + return true + } + } + return false +} + +// None returns true if no elements satisfy the predicate. +// +// Example: +// +// numbers := []interface{}{1, 3, 5, 7} +// isEven := func(v interface{}) bool { return v.(int)%2 == 0 } +// result := None(numbers, isEven) // true (no even numbers) +func None(values []interface{}, fn Predicate) bool { + for _, v := range values { + if fn(v) { + return false + } + } + return true +} + +// Chunk splits a slice into chunks of the given size. +// +// Example: +// +// numbers := []interface{}{1, 2, 3, 4, 5} +// result := Chunk(numbers, 2) // [[1,2], [3,4], [5]] +func Chunk(values []interface{}, size int) [][]interface{} { + if size <= 0 { + return nil + } + var chunks [][]interface{} + for i := 0; i < len(values); i += size { + end := i + size + if end > len(values) { + end = len(values) + } + chunks = append(chunks, values[i:end]) + } + return chunks +} + +// Find returns the first element that satisfies the predicate and a boolean indicating if an element was found. +// +// Example: +// +// numbers := []interface{}{1, 2, 3, 4} +// isEven := func(v interface{}) bool { return v.(int)%2 == 0 } +// result, found := Find(numbers, isEven) // 2, true +func Find(values []interface{}, fn Predicate) (interface{}, bool) { + for _, v := range values { + if fn(v) { + return v, true + } + } + return nil, false +} + +// Reverse reverses the order of elements in a slice. +// +// Example: +// +// numbers := []interface{}{1, 2, 3} +// result := Reverse(numbers) // [3, 2, 1] +func Reverse(values []interface{}) []interface{} { + result := make([]interface{}, len(values)) + for i, v := range values { + result[len(values)-1-i] = v + } + return result +} + +// Zip combines two slices into a slice of pairs. If the slices have different lengths, +// extra elements from the longer slice are ignored. +// +// Example: +// +// a := []interface{}{1, 2, 3} +// b := []interface{}{"a", "b", "c"} +// result := Zip(a, b) // [[1,"a"], [2,"b"], [3,"c"]] +func Zip(a, b []interface{}) [][2]interface{} { + length := min(len(a), len(b)) + result := make([][2]interface{}, length) + for i := 0; i < length; i++ { + result[i] = [2]interface{}{a[i], b[i]} + } + return result +} + +// Unzip splits a slice of pairs into two separate slices. +// +// Example: +// +// pairs := [][2]interface{}{{1,"a"}, {2,"b"}, {3,"c"}} +// numbers, letters := Unzip(pairs) // [1,2,3], ["a","b","c"] +func Unzip(pairs [][2]interface{}) ([]interface{}, []interface{}) { + a := make([]interface{}, len(pairs)) + b := make([]interface{}, len(pairs)) + for i, pair := range pairs { + a[i] = pair[0] + b[i] = pair[1] + } + return a, b +} + +// GroupBy groups elements based on a key returned by a Mapper. +// +// Example: +// +// numbers := []interface{}{1, 2, 3, 4, 5, 6} +// byMod3 := func(v interface{}) interface{} { return v.(int) % 3 } +// result := GroupBy(numbers, byMod3) // {0: [3,6], 1: [1,4], 2: [2,5]} +func GroupBy(values []interface{}, fn Mapper) map[interface{}][]interface{} { + result := make(map[interface{}][]interface{}) + for _, v := range values { + key := fn(v) + result[key] = append(result[key], v) + } + return result +} + +// Flatten flattens a slice of slices into a single slice. +// +// Example: +// +// nested := [][]interface{}{{1,2}, {3,4}, {5}} +// result := Flatten(nested) // [1,2,3,4,5] +func Flatten(values [][]interface{}) []interface{} { + result := []interface{}{} + for _, v := range values { + result = append(result, v...) + } + return result +} + +// Helper functions +func min(a, b int) int { + if a < b { + return a + } + return b +} diff --git a/examples/gno.land/p/moul/fp/fp_test.gno b/examples/gno.land/p/moul/fp/fp_test.gno new file mode 100644 index 00000000000..00957486fe9 --- /dev/null +++ b/examples/gno.land/p/moul/fp/fp_test.gno @@ -0,0 +1,666 @@ +package fp + +import ( + "fmt" + "testing" +) + +func TestMap(t *testing.T) { + tests := []struct { + name string + input []interface{} + fn func(interface{}) interface{} + expected []interface{} + }{ + { + name: "multiply numbers by 2", + input: []interface{}{1, 2, 3}, + fn: func(v interface{}) interface{} { return v.(int) * 2 }, + expected: []interface{}{2, 4, 6}, + }, + { + name: "empty slice", + input: []interface{}{}, + fn: func(v interface{}) interface{} { return v.(int) * 2 }, + expected: []interface{}{}, + }, + { + name: "convert numbers to strings", + input: []interface{}{1, 2, 3}, + fn: func(v interface{}) interface{} { return fmt.Sprintf("%d", v.(int)) }, + expected: []interface{}{"1", "2", "3"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := Map(tt.input, tt.fn) + if !equalSlices(result, tt.expected) { + t.Errorf("Map failed, expected %v, got %v", tt.expected, result) + } + }) + } +} + +func TestFilter(t *testing.T) { + tests := []struct { + name string + input []interface{} + fn func(interface{}) bool + expected []interface{} + }{ + { + name: "filter even numbers", + input: []interface{}{1, 2, 3, 4}, + fn: func(v interface{}) bool { return v.(int)%2 == 0 }, + expected: []interface{}{2, 4}, + }, + { + name: "empty slice", + input: []interface{}{}, + fn: func(v interface{}) bool { return v.(int)%2 == 0 }, + expected: []interface{}{}, + }, + { + name: "no matches", + input: []interface{}{1, 3, 5}, + fn: func(v interface{}) bool { return v.(int)%2 == 0 }, + expected: []interface{}{}, + }, + { + name: "all matches", + input: []interface{}{2, 4, 6}, + fn: func(v interface{}) bool { return v.(int)%2 == 0 }, + expected: []interface{}{2, 4, 6}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := Filter(tt.input, tt.fn) + if !equalSlices(result, tt.expected) { + t.Errorf("Filter failed, expected %v, got %v", tt.expected, result) + } + }) + } +} + +func TestReduce(t *testing.T) { + tests := []struct { + name string + input []interface{} + fn func(interface{}, interface{}) interface{} + initial interface{} + expected interface{} + }{ + { + name: "sum numbers", + input: []interface{}{1, 2, 3}, + fn: func(a, b interface{}) interface{} { return a.(int) + b.(int) }, + initial: 0, + expected: 6, + }, + { + name: "empty slice", + input: []interface{}{}, + fn: func(a, b interface{}) interface{} { return a.(int) + b.(int) }, + initial: 0, + expected: 0, + }, + { + name: "concatenate strings", + input: []interface{}{"a", "b", "c"}, + fn: func(a, b interface{}) interface{} { return a.(string) + b.(string) }, + initial: "", + expected: "abc", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := Reduce(tt.input, tt.fn, tt.initial) + if result != tt.expected { + t.Errorf("Reduce failed, expected %v, got %v", tt.expected, result) + } + }) + } +} + +func TestFlatMap(t *testing.T) { + tests := []struct { + name string + input []interface{} + fn func(interface{}) interface{} + expected []interface{} + }{ + { + name: "split words into chars", + input: []interface{}{"go", "fn"}, + fn: func(word interface{}) interface{} { + chars := []interface{}{} + for _, c := range word.(string) { + chars = append(chars, string(c)) + } + return chars + }, + expected: []interface{}{"g", "o", "f", "n"}, + }, + { + name: "empty string handling", + input: []interface{}{"", "a", ""}, + fn: func(word interface{}) interface{} { + chars := []interface{}{} + for _, c := range word.(string) { + chars = append(chars, string(c)) + } + return chars + }, + expected: []interface{}{"a"}, + }, + { + name: "nil handling", + input: []interface{}{nil, "a", nil}, + fn: func(word interface{}) interface{} { + if word == nil { + return []interface{}{} + } + return []interface{}{word} + }, + expected: []interface{}{"a"}, + }, + { + name: "empty slice result", + input: []interface{}{"", "", ""}, + fn: func(word interface{}) interface{} { + return []interface{}{} + }, + expected: []interface{}{}, + }, + { + name: "nested array flattening", + input: []interface{}{1, 2, 3}, + fn: func(n interface{}) interface{} { + return []interface{}{n, n} + }, + expected: []interface{}{1, 1, 2, 2, 3, 3}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := FlatMap(tt.input, tt.fn) + if !equalSlices(result, tt.expected) { + t.Errorf("FlatMap failed, expected %v, got %v", tt.expected, result) + } + }) + } +} + +func TestAllAnyNone(t *testing.T) { + tests := []struct { + name string + input []interface{} + fn func(interface{}) bool + expectedAll bool + expectedAny bool + expectedNone bool + }{ + { + name: "all even numbers", + input: []interface{}{2, 4, 6, 8}, + fn: func(x interface{}) bool { return x.(int)%2 == 0 }, + expectedAll: true, + expectedAny: true, + expectedNone: false, + }, + { + name: "no even numbers", + input: []interface{}{1, 3, 5, 7}, + fn: func(x interface{}) bool { return x.(int)%2 == 0 }, + expectedAll: false, + expectedAny: false, + expectedNone: true, + }, + { + name: "mixed even/odd numbers", + input: []interface{}{1, 2, 3, 4}, + fn: func(x interface{}) bool { return x.(int)%2 == 0 }, + expectedAll: false, + expectedAny: true, + expectedNone: false, + }, + { + name: "empty slice", + input: []interface{}{}, + fn: func(x interface{}) bool { return x.(int)%2 == 0 }, + expectedAll: true, // vacuously true + expectedAny: false, // vacuously false + expectedNone: true, // vacuously true + }, + { + name: "nil predicate handling", + input: []interface{}{nil, nil, nil}, + fn: func(x interface{}) bool { return x == nil }, + expectedAll: true, + expectedAny: true, + expectedNone: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + resultAll := All(tt.input, tt.fn) + if resultAll != tt.expectedAll { + t.Errorf("All failed, expected %v, got %v", tt.expectedAll, resultAll) + } + + resultAny := Any(tt.input, tt.fn) + if resultAny != tt.expectedAny { + t.Errorf("Any failed, expected %v, got %v", tt.expectedAny, resultAny) + } + + resultNone := None(tt.input, tt.fn) + if resultNone != tt.expectedNone { + t.Errorf("None failed, expected %v, got %v", tt.expectedNone, resultNone) + } + }) + } +} + +func TestChunk(t *testing.T) { + tests := []struct { + name string + input []interface{} + size int + expected [][]interface{} + }{ + { + name: "normal chunks", + input: []interface{}{1, 2, 3, 4, 5}, + size: 2, + expected: [][]interface{}{{1, 2}, {3, 4}, {5}}, + }, + { + name: "empty slice", + input: []interface{}{}, + size: 2, + expected: [][]interface{}{}, + }, + { + name: "chunk size equals length", + input: []interface{}{1, 2, 3}, + size: 3, + expected: [][]interface{}{{1, 2, 3}}, + }, + { + name: "chunk size larger than length", + input: []interface{}{1, 2}, + size: 3, + expected: [][]interface{}{{1, 2}}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := Chunk(tt.input, tt.size) + if !equalNestedSlices(result, tt.expected) { + t.Errorf("Chunk failed, expected %v, got %v", tt.expected, result) + } + }) + } +} + +func TestFind(t *testing.T) { + tests := []struct { + name string + input []interface{} + fn func(interface{}) bool + expected interface{} + shouldFound bool + }{ + { + name: "find first number greater than 2", + input: []interface{}{1, 2, 3, 4}, + fn: func(v interface{}) bool { return v.(int) > 2 }, + expected: 3, + shouldFound: true, + }, + { + name: "empty slice", + input: []interface{}{}, + fn: func(v interface{}) bool { return v.(int) > 2 }, + expected: nil, + shouldFound: false, + }, + { + name: "no match", + input: []interface{}{1, 2}, + fn: func(v interface{}) bool { return v.(int) > 10 }, + expected: nil, + shouldFound: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, found := Find(tt.input, tt.fn) + if found != tt.shouldFound { + t.Errorf("Find failed, expected found=%v, got found=%v", tt.shouldFound, found) + } + if found && result != tt.expected { + t.Errorf("Find failed, expected %v, got %v", tt.expected, result) + } + }) + } +} + +func TestReverse(t *testing.T) { + tests := []struct { + name string + input []interface{} + expected []interface{} + }{ + { + name: "normal sequence", + input: []interface{}{1, 2, 3, 4}, + expected: []interface{}{4, 3, 2, 1}, + }, + { + name: "empty slice", + input: []interface{}{}, + expected: []interface{}{}, + }, + { + name: "single element", + input: []interface{}{1}, + expected: []interface{}{1}, + }, + { + name: "mixed types", + input: []interface{}{1, "a", true, 2.5}, + expected: []interface{}{2.5, true, "a", 1}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := Reverse(tt.input) + if !equalSlices(result, tt.expected) { + t.Errorf("Reverse failed, expected %v, got %v", tt.expected, result) + } + }) + } +} + +func TestZipUnzip(t *testing.T) { + tests := []struct { + name string + a []interface{} + b []interface{} + expectedZip [][2]interface{} + expectedA []interface{} + expectedB []interface{} + }{ + { + name: "normal case", + a: []interface{}{1, 2, 3}, + b: []interface{}{"a", "b", "c"}, + expectedZip: [][2]interface{}{{1, "a"}, {2, "b"}, {3, "c"}}, + expectedA: []interface{}{1, 2, 3}, + expectedB: []interface{}{"a", "b", "c"}, + }, + { + name: "empty slices", + a: []interface{}{}, + b: []interface{}{}, + expectedZip: [][2]interface{}{}, + expectedA: []interface{}{}, + expectedB: []interface{}{}, + }, + { + name: "different lengths - a shorter", + a: []interface{}{1, 2}, + b: []interface{}{"a", "b", "c"}, + expectedZip: [][2]interface{}{{1, "a"}, {2, "b"}}, + expectedA: []interface{}{1, 2}, + expectedB: []interface{}{"a", "b"}, + }, + { + name: "different lengths - b shorter", + a: []interface{}{1, 2, 3}, + b: []interface{}{"a"}, + expectedZip: [][2]interface{}{{1, "a"}}, + expectedA: []interface{}{1}, + expectedB: []interface{}{"a"}, + }, + { + name: "mixed types", + a: []interface{}{1, true, "x"}, + b: []interface{}{2.5, false, "y"}, + expectedZip: [][2]interface{}{{1, 2.5}, {true, false}, {"x", "y"}}, + expectedA: []interface{}{1, true, "x"}, + expectedB: []interface{}{2.5, false, "y"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + zipped := Zip(tt.a, tt.b) + if len(zipped) != len(tt.expectedZip) { + t.Errorf("Zip failed, expected length %v, got %v", len(tt.expectedZip), len(zipped)) + } + for i, pair := range zipped { + if pair[0] != tt.expectedZip[i][0] || pair[1] != tt.expectedZip[i][1] { + t.Errorf("Zip failed at index %d, expected %v, got %v", i, tt.expectedZip[i], pair) + } + } + + unzippedA, unzippedB := Unzip(zipped) + if !equalSlices(unzippedA, tt.expectedA) { + t.Errorf("Unzip failed for slice A, expected %v, got %v", tt.expectedA, unzippedA) + } + if !equalSlices(unzippedB, tt.expectedB) { + t.Errorf("Unzip failed for slice B, expected %v, got %v", tt.expectedB, unzippedB) + } + }) + } +} + +func TestGroupBy(t *testing.T) { + tests := []struct { + name string + input []interface{} + fn func(interface{}) interface{} + expected map[interface{}][]interface{} + }{ + { + name: "group by even/odd", + input: []interface{}{1, 2, 3, 4, 5, 6}, + fn: func(v interface{}) interface{} { return v.(int) % 2 }, + expected: map[interface{}][]interface{}{ + 0: {2, 4, 6}, + 1: {1, 3, 5}, + }, + }, + { + name: "empty slice", + input: []interface{}{}, + fn: func(v interface{}) interface{} { return v.(int) % 2 }, + expected: map[interface{}][]interface{}{}, + }, + { + name: "single group", + input: []interface{}{2, 4, 6}, + fn: func(v interface{}) interface{} { return v.(int) % 2 }, + expected: map[interface{}][]interface{}{ + 0: {2, 4, 6}, + }, + }, + { + name: "group by type", + input: []interface{}{1, "a", 2, "b", true}, + fn: func(v interface{}) interface{} { + switch v.(type) { + case int: + return "int" + case string: + return "string" + default: + return "other" + } + }, + expected: map[interface{}][]interface{}{ + "int": {1, 2}, + "string": {"a", "b"}, + "other": {true}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := GroupBy(tt.input, tt.fn) + if len(result) != len(tt.expected) { + t.Errorf("GroupBy failed, expected %d groups, got %d", len(tt.expected), len(result)) + } + for k, v := range tt.expected { + if !equalSlices(result[k], v) { + t.Errorf("GroupBy failed for key %v, expected %v, got %v", k, v, result[k]) + } + } + }) + } +} + +func TestFlatten(t *testing.T) { + tests := []struct { + name string + input [][]interface{} + expected []interface{} + }{ + { + name: "normal nested slices", + input: [][]interface{}{{1, 2}, {3, 4}, {5}}, + expected: []interface{}{1, 2, 3, 4, 5}, + }, + { + name: "empty outer slice", + input: [][]interface{}{}, + expected: []interface{}{}, + }, + { + name: "empty inner slices", + input: [][]interface{}{{}, {}, {}}, + expected: []interface{}{}, + }, + { + name: "mixed types", + input: [][]interface{}{{1, "a"}, {true, 2.5}, {nil}}, + expected: []interface{}{1, "a", true, 2.5, nil}, + }, + { + name: "single element slices", + input: [][]interface{}{{1}, {2}, {3}}, + expected: []interface{}{1, 2, 3}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := Flatten(tt.input) + if !equalSlices(result, tt.expected) { + t.Errorf("Flatten failed, expected %v, got %v", tt.expected, result) + } + }) + } +} + +func TestContains(t *testing.T) { + tests := []struct { + name string + slice []interface{} + item interface{} + expected bool + }{ + { + name: "contains integer", + slice: []interface{}{1, 2, 3}, + item: 2, + expected: true, + }, + { + name: "does not contain integer", + slice: []interface{}{1, 2, 3}, + item: 4, + expected: false, + }, + { + name: "contains string", + slice: []interface{}{"a", "b", "c"}, + item: "b", + expected: true, + }, + { + name: "empty slice", + slice: []interface{}{}, + item: 1, + expected: false, + }, + { + name: "contains nil", + slice: []interface{}{1, nil, 3}, + item: nil, + expected: true, + }, + { + name: "mixed types", + slice: []interface{}{1, "a", true}, + item: true, + expected: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := contains(tt.slice, tt.item) + if result != tt.expected { + t.Errorf("contains failed, expected %v, got %v", tt.expected, result) + } + }) + } +} + +// Helper function for testing +func contains(slice []interface{}, item interface{}) bool { + for _, v := range slice { + if v == item { + return true + } + } + return false +} + +// Helper functions for comparing slices +func equalSlices(a, b []interface{}) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +func equalNestedSlices(a, b [][]interface{}) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if !equalSlices(a[i], b[i]) { + return false + } + } + return true +} diff --git a/examples/gno.land/p/moul/fp/gno.mod b/examples/gno.land/p/moul/fp/gno.mod new file mode 100644 index 00000000000..905fa0f1c0e --- /dev/null +++ b/examples/gno.land/p/moul/fp/gno.mod @@ -0,0 +1 @@ +module gno.land/p/moul/fp diff --git a/examples/gno.land/p/moul/memo/gno.mod b/examples/gno.land/p/moul/memo/gno.mod new file mode 100644 index 00000000000..4a9948c30f7 --- /dev/null +++ b/examples/gno.land/p/moul/memo/gno.mod @@ -0,0 +1 @@ +module gno.land/p/moul/memo diff --git a/examples/gno.land/p/moul/memo/memo.gno b/examples/gno.land/p/moul/memo/memo.gno new file mode 100644 index 00000000000..e31f13aab15 --- /dev/null +++ b/examples/gno.land/p/moul/memo/memo.gno @@ -0,0 +1,134 @@ +// Package memo provides a simple memoization utility to cache function results. +// +// The package offers a Memoizer type that can cache function results based on keys, +// with optional validation of cached values. This is useful for expensive computations +// that need to be cached and potentially invalidated based on custom conditions. +// +// /!\ Important Warning for Gno Usage: +// In Gno, storage updates only persist during transactions. This means: +// - Cache entries created during queries will NOT persist +// - Creating cache entries during queries will actually decrease performance +// as it wastes resources trying to save data that won't be saved +// +// Best Practices: +// - Use this pattern in transaction-driven contexts rather than query/render scenarios +// - Consider controlled cache updates, e.g., by specific accounts (like oracles) +// - Ideal for cases where cache updates happen every N blocks or on specific events +// - Carefully evaluate if caching will actually improve performance in your use case +// +// Basic usage example: +// +// m := memo.New() +// +// // Cache expensive computation +// result := m.Memoize("key", func() interface{} { +// // expensive operation +// return "computed-value" +// }) +// +// // Subsequent calls with same key return cached result +// result = m.Memoize("key", func() interface{} { +// // function won't be called, cached value is returned +// return "computed-value" +// }) +// +// Example with validation: +// +// type TimestampedValue struct { +// Value string +// Timestamp time.Time +// } +// +// m := memo.New() +// +// // Cache value with timestamp +// result := m.MemoizeWithValidator( +// "key", +// func() interface{} { +// return TimestampedValue{ +// Value: "data", +// Timestamp: time.Now(), +// } +// }, +// func(cached interface{}) bool { +// // Validate that the cached value is not older than 1 hour +// if tv, ok := cached.(TimestampedValue); ok { +// return time.Since(tv.Timestamp) < time.Hour +// } +// return false +// }, +// ) +package memo + +import ( + "gno.land/p/demo/btree" + "gno.land/p/demo/ufmt" +) + +// Record implements the btree.Record interface for our cache entries +type cacheEntry struct { + key interface{} + value interface{} +} + +// Less implements btree.Record interface +func (e cacheEntry) Less(than btree.Record) bool { + // Convert the other record to cacheEntry + other := than.(cacheEntry) + // Compare string representations of keys for consistent ordering + return ufmt.Sprintf("%v", e.key) < ufmt.Sprintf("%v", other.key) +} + +// Memoizer is a structure to handle memoization of function results. +type Memoizer struct { + cache *btree.BTree +} + +// New creates a new Memoizer instance. +func New() *Memoizer { + return &Memoizer{ + cache: btree.New(), + } +} + +// Memoize ensures the result of the given function is cached for the specified key. +func (m *Memoizer) Memoize(key interface{}, fn func() interface{}) interface{} { + entry := cacheEntry{key: key} + if found := m.cache.Get(entry); found != nil { + return found.(cacheEntry).value + } + + value := fn() + m.cache.Insert(cacheEntry{key: key, value: value}) + return value +} + +// MemoizeWithValidator ensures the result is cached and valid according to the validator function. +func (m *Memoizer) MemoizeWithValidator(key interface{}, fn func() interface{}, isValid func(interface{}) bool) interface{} { + entry := cacheEntry{key: key} + if found := m.cache.Get(entry); found != nil { + cachedEntry := found.(cacheEntry) + if isValid(cachedEntry.value) { + return cachedEntry.value + } + } + + value := fn() + m.cache.Insert(cacheEntry{key: key, value: value}) + return value +} + +// Invalidate removes the cached value for the specified key. +func (m *Memoizer) Invalidate(key interface{}) { + m.cache.Delete(cacheEntry{key: key}) +} + +// Clear clears all cached values. +func (m *Memoizer) Clear() { + m.cache.Clear(true) +} + +// Size returns the number of items currently in the cache. +func (m *Memoizer) Size() int { + return m.cache.Len() +} diff --git a/examples/gno.land/p/moul/memo/memo_test.gno b/examples/gno.land/p/moul/memo/memo_test.gno new file mode 100644 index 00000000000..44dde5df640 --- /dev/null +++ b/examples/gno.land/p/moul/memo/memo_test.gno @@ -0,0 +1,449 @@ +package memo + +import ( + "std" + "testing" + "time" +) + +type timestampedValue struct { + value interface{} + timestamp time.Time +} + +// complexKey is used to test struct keys +type complexKey struct { + ID int + Name string +} + +func TestMemoize(t *testing.T) { + tests := []struct { + name string + key interface{} + value interface{} + callCount *int + }{ + { + name: "string key and value", + key: "test-key", + value: "test-value", + callCount: new(int), + }, + { + name: "int key and value", + key: 42, + value: 123, + callCount: new(int), + }, + { + name: "mixed types", + key: "number", + value: 42, + callCount: new(int), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + m := New() + if m.Size() != 0 { + t.Errorf("Initial size = %d, want 0", m.Size()) + } + + fn := func() interface{} { + *tt.callCount++ + return tt.value + } + + // First call should compute + result := m.Memoize(tt.key, fn) + if result != tt.value { + t.Errorf("Memoize() = %v, want %v", result, tt.value) + } + if *tt.callCount != 1 { + t.Errorf("Function called %d times, want 1", *tt.callCount) + } + if m.Size() != 1 { + t.Errorf("Size after first call = %d, want 1", m.Size()) + } + + // Second call should use cache + result = m.Memoize(tt.key, fn) + if result != tt.value { + t.Errorf("Memoize() second call = %v, want %v", result, tt.value) + } + if *tt.callCount != 1 { + t.Errorf("Function called %d times, want 1", *tt.callCount) + } + if m.Size() != 1 { + t.Errorf("Size after second call = %d, want 1", m.Size()) + } + }) + } +} + +func TestMemoizeWithValidator(t *testing.T) { + tests := []struct { + name string + key interface{} + value interface{} + validDuration time.Duration + waitDuration time.Duration + expectedCalls int + shouldRecompute bool + }{ + { + name: "valid cache", + key: "key1", + value: "value1", + validDuration: time.Hour, + waitDuration: time.Millisecond, + expectedCalls: 1, + shouldRecompute: false, + }, + { + name: "expired cache", + key: "key2", + value: "value2", + validDuration: time.Millisecond, + waitDuration: time.Millisecond * 2, + expectedCalls: 2, + shouldRecompute: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + m := New() + callCount := 0 + + fn := func() interface{} { + callCount++ + return timestampedValue{ + value: tt.value, + timestamp: time.Now(), + } + } + + isValid := func(cached interface{}) bool { + if tv, ok := cached.(timestampedValue); ok { + return time.Since(tv.timestamp) < tt.validDuration + } + return false + } + + // First call + result := m.MemoizeWithValidator(tt.key, fn, isValid) + if tv, ok := result.(timestampedValue); !ok || tv.value != tt.value { + t.Errorf("MemoizeWithValidator() = %v, want value %v", result, tt.value) + } + + // Wait + std.TestSkipHeights(10) + + // Second call + result = m.MemoizeWithValidator(tt.key, fn, isValid) + if tv, ok := result.(timestampedValue); !ok || tv.value != tt.value { + t.Errorf("MemoizeWithValidator() second call = %v, want value %v", result, tt.value) + } + + if callCount != tt.expectedCalls { + t.Errorf("Function called %d times, want %d", callCount, tt.expectedCalls) + } + }) + } +} + +func TestInvalidate(t *testing.T) { + tests := []struct { + name string + key interface{} + value interface{} + callCount *int + }{ + { + name: "invalidate existing key", + key: "test-key", + value: "test-value", + callCount: new(int), + }, + { + name: "invalidate non-existing key", + key: "missing-key", + value: "test-value", + callCount: new(int), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + m := New() + fn := func() interface{} { + *tt.callCount++ + return tt.value + } + + // First call + m.Memoize(tt.key, fn) + if m.Size() != 1 { + t.Errorf("Size after first call = %d, want 1", m.Size()) + } + + // Invalidate + m.Invalidate(tt.key) + if m.Size() != 0 { + t.Errorf("Size after invalidate = %d, want 0", m.Size()) + } + + // Call again should recompute + result := m.Memoize(tt.key, fn) + if result != tt.value { + t.Errorf("Memoize() after invalidate = %v, want %v", result, tt.value) + } + if *tt.callCount != 2 { + t.Errorf("Function called %d times, want 2", *tt.callCount) + } + if m.Size() != 1 { + t.Errorf("Size after recompute = %d, want 1", m.Size()) + } + }) + } +} + +func TestClear(t *testing.T) { + m := New() + callCount := 0 + + fn := func() interface{} { + callCount++ + return "value" + } + + // Cache some values + m.Memoize("key1", fn) + m.Memoize("key2", fn) + + if callCount != 2 { + t.Errorf("Initial calls = %d, want 2", callCount) + } + if m.Size() != 2 { + t.Errorf("Size after initial calls = %d, want 2", m.Size()) + } + + // Clear cache + m.Clear() + if m.Size() != 0 { + t.Errorf("Size after clear = %d, want 0", m.Size()) + } + + // Recompute values + m.Memoize("key1", fn) + m.Memoize("key2", fn) + + if callCount != 4 { + t.Errorf("Calls after clear = %d, want 4", callCount) + } + if m.Size() != 2 { + t.Errorf("Size after recompute = %d, want 2", m.Size()) + } +} + +func TestSize(t *testing.T) { + m := New() + + if m.Size() != 0 { + t.Errorf("Initial size = %d, want 0", m.Size()) + } + + callCount := 0 + fn := func() interface{} { + callCount++ + return "value" + } + + // Add items + m.Memoize("key1", fn) + if m.Size() != 1 { + t.Errorf("Size after first insert = %d, want 1", m.Size()) + } + + m.Memoize("key2", fn) + if m.Size() != 2 { + t.Errorf("Size after second insert = %d, want 2", m.Size()) + } + + // Duplicate key should not increase size + m.Memoize("key1", fn) + if m.Size() != 2 { + t.Errorf("Size after duplicate insert = %d, want 2", m.Size()) + } + + // Remove item + m.Invalidate("key1") + if m.Size() != 1 { + t.Errorf("Size after invalidate = %d, want 1", m.Size()) + } + + // Clear all + m.Clear() + if m.Size() != 0 { + t.Errorf("Size after clear = %d, want 0", m.Size()) + } +} + +func TestMemoizeWithDifferentKeyTypes(t *testing.T) { + tests := []struct { + name string + keys []interface{} // Now an array of keys + values []string // Corresponding values + callCount *int + }{ + { + name: "integer keys", + keys: []interface{}{42, 43}, + values: []string{"value-for-42", "value-for-43"}, + callCount: new(int), + }, + { + name: "float keys", + keys: []interface{}{3.14, 2.718}, + values: []string{"value-for-pi", "value-for-e"}, + callCount: new(int), + }, + { + name: "bool keys", + keys: []interface{}{true, false}, + values: []string{"value-for-true", "value-for-false"}, + callCount: new(int), + }, + /* + { + name: "struct keys", + keys: []interface{}{ + complexKey{ID: 1, Name: "test1"}, + complexKey{ID: 2, Name: "test2"}, + }, + values: []string{"value-for-struct1", "value-for-struct2"}, + callCount: new(int), + }, + { + name: "nil and empty interface keys", + keys: []interface{}{nil, interface{}(nil)}, + values: []string{"value-for-nil", "value-for-empty-interface"}, + callCount: new(int), + }, + */ + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + m := New() + + // Test both keys + for i, key := range tt.keys { + value := tt.values[i] + fn := func() interface{} { + *tt.callCount++ + return value + } + + // First call should compute + result := m.Memoize(key, fn) + if result != value { + t.Errorf("Memoize() for key %v = %v, want %v", key, result, value) + } + if *tt.callCount != i+1 { + t.Errorf("Function called %d times, want %d", *tt.callCount, i+1) + } + } + + // Verify size includes both entries + if m.Size() != 2 { + t.Errorf("Size after both inserts = %d, want 2", m.Size()) + } + + // Second call for each key should use cache + for i, key := range tt.keys { + initialCount := *tt.callCount + result := m.Memoize(key, func() interface{} { + *tt.callCount++ + return "should-not-be-called" + }) + + if result != tt.values[i] { + t.Errorf("Memoize() second call for key %v = %v, want %v", key, result, tt.values[i]) + } + if *tt.callCount != initialCount { + t.Errorf("Cache miss for key %v", key) + } + } + + // Test invalidate for each key + for i, key := range tt.keys { + m.Invalidate(key) + if m.Size() != 1-i { + t.Errorf("Size after invalidate %d = %d, want %d", i+1, m.Size(), 1-i) + } + } + }) + } +} + +func TestMultipleKeyTypes(t *testing.T) { + m := New() + callCount := 0 + + // Insert different key types simultaneously (two of each type) + keys := []interface{}{ + 42, 43, // ints + "string-key1", "string-key2", // strings + 3.14, 2.718, // floats + true, false, // bools + } + + for i, key := range keys { + value := i + m.Memoize(key, func() interface{} { + callCount++ + return value + }) + } + + // Verify size includes all entries + if m.Size() != len(keys) { + t.Errorf("Size = %d, want %d", m.Size(), len(keys)) + } + + // Verify all values are cached correctly + for i, key := range keys { + initialCount := callCount + result := m.Memoize(key, func() interface{} { + callCount++ + return -1 // Should never be returned if cache works + }) + + if result != i { + t.Errorf("Memoize(%v) = %v, want %v", key, result, i) + } + if callCount != initialCount { + t.Errorf("Cache miss for key %v", key) + } + } + + // Test invalidation of pairs + for i := 0; i < len(keys); i += 2 { + m.Invalidate(keys[i]) + m.Invalidate(keys[i+1]) + expectedSize := len(keys) - (i + 2) + if m.Size() != expectedSize { + t.Errorf("Size after invalidating pair %d = %d, want %d", i/2, m.Size(), expectedSize) + } + } + + // Clear and verify + m.Clear() + if m.Size() != 0 { + t.Errorf("Size after clear = %d, want 0", m.Size()) + } +} diff --git a/examples/gno.land/p/moul/typeutil/gno.mod b/examples/gno.land/p/moul/typeutil/gno.mod new file mode 100644 index 00000000000..4f9c432456b --- /dev/null +++ b/examples/gno.land/p/moul/typeutil/gno.mod @@ -0,0 +1 @@ +module gno.land/p/moul/typeutil diff --git a/examples/gno.land/p/moul/typeutil/typeutil.gno b/examples/gno.land/p/moul/typeutil/typeutil.gno new file mode 100644 index 00000000000..1fa79b94549 --- /dev/null +++ b/examples/gno.land/p/moul/typeutil/typeutil.gno @@ -0,0 +1,715 @@ +// Package typeutil provides utility functions for converting between different types +// and checking their states. It aims to provide consistent behavior across different +// types while remaining lightweight and dependency-free. +package typeutil + +import ( + "errors" + "sort" + "std" + "strconv" + "strings" + "time" +) + +// stringer is the interface that wraps the String method. +type stringer interface { + String() string +} + +// ToString converts any value to its string representation. +// It supports a wide range of Go types including: +// - Basic: string, bool +// - Numbers: int, int8-64, uint, uint8-64, float32, float64 +// - Special: time.Time, std.Address, []byte +// - Slices: []T for most basic types +// - Maps: map[string]string, map[string]interface{} +// - Interface: types implementing String() string +// +// Example usage: +// +// str := typeutil.ToString(42) // "42" +// str = typeutil.ToString([]int{1, 2}) // "[1 2]" +// str = typeutil.ToString(map[string]string{ // "map[a:1 b:2]" +// "a": "1", +// "b": "2", +// }) +func ToString(val interface{}) string { + if val == nil { + return "" + } + + // First check if value implements Stringer interface + if s, ok := val.(interface{ String() string }); ok { + return s.String() + } + + switch v := val.(type) { + // Pointer types - dereference and recurse + case *string: + if v == nil { + return "" + } + return *v + case *int: + if v == nil { + return "" + } + return strconv.Itoa(*v) + case *bool: + if v == nil { + return "" + } + return strconv.FormatBool(*v) + case *time.Time: + if v == nil { + return "" + } + return v.String() + case *std.Address: + if v == nil { + return "" + } + return string(*v) + + // String types + case string: + return v + case stringer: + return v.String() + + // Special types + case time.Time: + return v.String() + case std.Address: + return string(v) + case []byte: + return string(v) + case struct{}: + return "{}" + + // Integer types + case int: + return strconv.Itoa(v) + case int8: + return strconv.FormatInt(int64(v), 10) + case int16: + return strconv.FormatInt(int64(v), 10) + case int32: + return strconv.FormatInt(int64(v), 10) + case int64: + return strconv.FormatInt(v, 10) + case uint: + return strconv.FormatUint(uint64(v), 10) + case uint8: + return strconv.FormatUint(uint64(v), 10) + case uint16: + return strconv.FormatUint(uint64(v), 10) + case uint32: + return strconv.FormatUint(uint64(v), 10) + case uint64: + return strconv.FormatUint(v, 10) + + // Float types + case float32: + return strconv.FormatFloat(float64(v), 'f', -1, 32) + case float64: + return strconv.FormatFloat(v, 'f', -1, 64) + + // Boolean + case bool: + if v { + return "true" + } + return "false" + + // Slice types + case []string: + return join(v) + case []int: + return join(v) + case []int32: + return join(v) + case []int64: + return join(v) + case []float32: + return join(v) + case []float64: + return join(v) + case []interface{}: + return join(v) + case []time.Time: + return joinTimes(v) + case []stringer: + return join(v) + case []std.Address: + return joinAddresses(v) + case [][]byte: + return joinBytes(v) + + // Map types with various key types + case map[interface{}]interface{}, map[string]interface{}, map[string]string, map[string]int: + var b strings.Builder + b.WriteString("map[") + first := true + + switch m := v.(type) { + case map[interface{}]interface{}: + // Convert all keys to strings for consistent ordering + keys := make([]string, 0) + keyMap := make(map[string]interface{}) + + for k := range m { + keyStr := ToString(k) + keys = append(keys, keyStr) + keyMap[keyStr] = k + } + sort.Strings(keys) + + for _, keyStr := range keys { + if !first { + b.WriteString(" ") + } + origKey := keyMap[keyStr] + b.WriteString(keyStr) + b.WriteString(":") + b.WriteString(ToString(m[origKey])) + first = false + } + + case map[string]interface{}: + keys := make([]string, 0) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + + for _, k := range keys { + if !first { + b.WriteString(" ") + } + b.WriteString(k) + b.WriteString(":") + b.WriteString(ToString(m[k])) + first = false + } + + case map[string]string: + keys := make([]string, 0) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + + for _, k := range keys { + if !first { + b.WriteString(" ") + } + b.WriteString(k) + b.WriteString(":") + b.WriteString(m[k]) + first = false + } + + case map[string]int: + keys := make([]string, 0) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + + for _, k := range keys { + if !first { + b.WriteString(" ") + } + b.WriteString(k) + b.WriteString(":") + b.WriteString(strconv.Itoa(m[k])) + first = false + } + } + b.WriteString("]") + return b.String() + + // Default + default: + return "" + } +} + +func join(slice interface{}) string { + if IsZero(slice) { + return "[]" + } + + items := ToInterfaceSlice(slice) + if items == nil { + return "[]" + } + + var b strings.Builder + b.WriteString("[") + for i, item := range items { + if i > 0 { + b.WriteString(" ") + } + b.WriteString(ToString(item)) + } + b.WriteString("]") + return b.String() +} + +func joinTimes(slice []time.Time) string { + if len(slice) == 0 { + return "[]" + } + var b strings.Builder + b.WriteString("[") + for i, t := range slice { + if i > 0 { + b.WriteString(" ") + } + b.WriteString(t.String()) + } + b.WriteString("]") + return b.String() +} + +func joinAddresses(slice []std.Address) string { + if len(slice) == 0 { + return "[]" + } + var b strings.Builder + b.WriteString("[") + for i, addr := range slice { + if i > 0 { + b.WriteString(" ") + } + b.WriteString(string(addr)) + } + b.WriteString("]") + return b.String() +} + +func joinBytes(slice [][]byte) string { + if len(slice) == 0 { + return "[]" + } + var b strings.Builder + b.WriteString("[") + for i, bytes := range slice { + if i > 0 { + b.WriteString(" ") + } + b.WriteString(string(bytes)) + } + b.WriteString("]") + return b.String() +} + +// ToBool converts any value to a boolean based on common programming conventions. +// For example: +// - Numbers: 0 is false, any other number is true +// - Strings: "", "0", "false", "f", "no", "n", "off" are false, others are true +// - Slices/Maps: empty is false, non-empty is true +// - nil: always false +// - bool: direct value +func ToBool(val interface{}) bool { + if IsZero(val) { + return false + } + + // Handle special string cases + if str, ok := val.(string); ok { + str = strings.ToLower(strings.TrimSpace(str)) + return str != "" && str != "0" && str != "false" && str != "f" && str != "no" && str != "n" && str != "off" + } + + return true +} + +// IsZero returns true if the value represents a "zero" or "empty" state for its type. +// For example: +// - Numbers: 0 +// - Strings: "" +// - Slices/Maps: empty +// - nil: true +// - bool: false +// - time.Time: IsZero() +// - std.Address: empty string +func IsZero(val interface{}) bool { + if val == nil { + return true + } + + switch v := val.(type) { + // Pointer types - nil pointer is zero, otherwise check pointed value + case *bool: + return v == nil || !*v + case *string: + return v == nil || *v == "" + case *int: + return v == nil || *v == 0 + case *time.Time: + return v == nil || v.IsZero() + case *std.Address: + return v == nil || string(*v) == "" + + // Bool + case bool: + return !v + + // String types + case string: + return v == "" + case stringer: + return v.String() == "" + + // Integer types + case int: + return v == 0 + case int8: + return v == 0 + case int16: + return v == 0 + case int32: + return v == 0 + case int64: + return v == 0 + case uint: + return v == 0 + case uint8: + return v == 0 + case uint16: + return v == 0 + case uint32: + return v == 0 + case uint64: + return v == 0 + + // Float types + case float32: + return v == 0 + case float64: + return v == 0 + + // Special types + case []byte: + return len(v) == 0 + case time.Time: + return v.IsZero() + case std.Address: + return string(v) == "" + + // Slices (check if empty) + case []string: + return len(v) == 0 + case []int: + return len(v) == 0 + case []int32: + return len(v) == 0 + case []int64: + return len(v) == 0 + case []float32: + return len(v) == 0 + case []float64: + return len(v) == 0 + case []interface{}: + return len(v) == 0 + case []time.Time: + return len(v) == 0 + case []std.Address: + return len(v) == 0 + case [][]byte: + return len(v) == 0 + case []stringer: + return len(v) == 0 + + // Maps (check if empty) + case map[string]string: + return len(v) == 0 + case map[string]interface{}: + return len(v) == 0 + + default: + return false // non-nil unknown types are considered non-zero + } +} + +// ToInterfaceSlice converts various slice types to []interface{} +func ToInterfaceSlice(val interface{}) []interface{} { + switch v := val.(type) { + case []interface{}: + return v + case []string: + result := make([]interface{}, len(v)) + for i, s := range v { + result[i] = s + } + return result + case []int: + result := make([]interface{}, len(v)) + for i, n := range v { + result[i] = n + } + return result + case []int32: + result := make([]interface{}, len(v)) + for i, n := range v { + result[i] = n + } + return result + case []int64: + result := make([]interface{}, len(v)) + for i, n := range v { + result[i] = n + } + return result + case []float32: + result := make([]interface{}, len(v)) + for i, n := range v { + result[i] = n + } + return result + case []float64: + result := make([]interface{}, len(v)) + for i, n := range v { + result[i] = n + } + return result + case []bool: + result := make([]interface{}, len(v)) + for i, b := range v { + result[i] = b + } + return result + default: + return nil + } +} + +// ToMapStringInterface converts a map with string keys and any value type to map[string]interface{} +func ToMapStringInterface(m interface{}) (map[string]interface{}, error) { + result := make(map[string]interface{}) + + switch v := m.(type) { + case map[string]interface{}: + return v, nil + case map[string]string: + for k, val := range v { + result[k] = val + } + case map[string]int: + for k, val := range v { + result[k] = val + } + case map[string]int64: + for k, val := range v { + result[k] = val + } + case map[string]float64: + for k, val := range v { + result[k] = val + } + case map[string]bool: + for k, val := range v { + result[k] = val + } + case map[string][]string: + for k, val := range v { + result[k] = ToInterfaceSlice(val) + } + case map[string][]int: + for k, val := range v { + result[k] = ToInterfaceSlice(val) + } + case map[string][]interface{}: + for k, val := range v { + result[k] = val + } + case map[string]map[string]interface{}: + for k, val := range v { + result[k] = val + } + case map[string]map[string]string: + for k, val := range v { + if converted, err := ToMapStringInterface(val); err == nil { + result[k] = converted + } else { + return nil, errors.New("failed to convert nested map at key: " + k) + } + } + default: + return nil, errors.New("unsupported map type: " + ToString(m)) + } + + return result, nil +} + +// ToMapIntInterface converts a map with int keys and any value type to map[int]interface{} +func ToMapIntInterface(m interface{}) (map[int]interface{}, error) { + result := make(map[int]interface{}) + + switch v := m.(type) { + case map[int]interface{}: + return v, nil + case map[int]string: + for k, val := range v { + result[k] = val + } + case map[int]int: + for k, val := range v { + result[k] = val + } + case map[int]int64: + for k, val := range v { + result[k] = val + } + case map[int]float64: + for k, val := range v { + result[k] = val + } + case map[int]bool: + for k, val := range v { + result[k] = val + } + case map[int][]string: + for k, val := range v { + result[k] = ToInterfaceSlice(val) + } + case map[int][]int: + for k, val := range v { + result[k] = ToInterfaceSlice(val) + } + case map[int][]interface{}: + for k, val := range v { + result[k] = val + } + case map[int]map[string]interface{}: + for k, val := range v { + result[k] = val + } + case map[int]map[int]interface{}: + for k, val := range v { + result[k] = val + } + default: + return nil, errors.New("unsupported map type: " + ToString(m)) + } + + return result, nil +} + +// ToStringSlice converts various slice types to []string +func ToStringSlice(val interface{}) []string { + switch v := val.(type) { + case []string: + return v + case []interface{}: + result := make([]string, len(v)) + for i, item := range v { + result[i] = ToString(item) + } + return result + case []int: + result := make([]string, len(v)) + for i, n := range v { + result[i] = strconv.Itoa(n) + } + return result + case []int32: + result := make([]string, len(v)) + for i, n := range v { + result[i] = strconv.FormatInt(int64(n), 10) + } + return result + case []int64: + result := make([]string, len(v)) + for i, n := range v { + result[i] = strconv.FormatInt(n, 10) + } + return result + case []float32: + result := make([]string, len(v)) + for i, n := range v { + result[i] = strconv.FormatFloat(float64(n), 'f', -1, 32) + } + return result + case []float64: + result := make([]string, len(v)) + for i, n := range v { + result[i] = strconv.FormatFloat(n, 'f', -1, 64) + } + return result + case []bool: + result := make([]string, len(v)) + for i, b := range v { + result[i] = strconv.FormatBool(b) + } + return result + case []time.Time: + result := make([]string, len(v)) + for i, t := range v { + result[i] = t.String() + } + return result + case []std.Address: + result := make([]string, len(v)) + for i, addr := range v { + result[i] = string(addr) + } + return result + case [][]byte: + result := make([]string, len(v)) + for i, b := range v { + result[i] = string(b) + } + return result + case []stringer: + result := make([]string, len(v)) + for i, s := range v { + result[i] = s.String() + } + return result + case []uint: + result := make([]string, len(v)) + for i, n := range v { + result[i] = strconv.FormatUint(uint64(n), 10) + } + return result + case []uint8: + result := make([]string, len(v)) + for i, n := range v { + result[i] = strconv.FormatUint(uint64(n), 10) + } + return result + case []uint16: + result := make([]string, len(v)) + for i, n := range v { + result[i] = strconv.FormatUint(uint64(n), 10) + } + return result + case []uint32: + result := make([]string, len(v)) + for i, n := range v { + result[i] = strconv.FormatUint(uint64(n), 10) + } + return result + case []uint64: + result := make([]string, len(v)) + for i, n := range v { + result[i] = strconv.FormatUint(n, 10) + } + return result + default: + // Try to convert using reflection if it's a slice + if slice := ToInterfaceSlice(val); slice != nil { + result := make([]string, len(slice)) + for i, item := range slice { + result[i] = ToString(item) + } + return result + } + return nil + } +} diff --git a/examples/gno.land/p/moul/typeutil/typeutil_test.gno b/examples/gno.land/p/moul/typeutil/typeutil_test.gno new file mode 100644 index 00000000000..543ea1deec4 --- /dev/null +++ b/examples/gno.land/p/moul/typeutil/typeutil_test.gno @@ -0,0 +1,1075 @@ +package typeutil + +import ( + "std" + "strings" + "testing" + "time" +) + +type testStringer struct { + value string +} + +func (t testStringer) String() string { + return "test:" + t.value +} + +func TestToString(t *testing.T) { + // setup test data + str := "hello" + num := 42 + b := true + now := time.Now() + addr := std.Address("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5") + stringer := testStringer{value: "hello"} + + type testCase struct { + name string + input interface{} + expected string + } + + tests := []testCase{ + // basic types + {"string", "hello", "hello"}, + {"empty_string", "", ""}, + {"nil", nil, ""}, + + // integer types + {"int", 42, "42"}, + {"int8", int8(8), "8"}, + {"int16", int16(16), "16"}, + {"int32", int32(32), "32"}, + {"int64", int64(64), "64"}, + {"uint", uint(42), "42"}, + {"uint8", uint8(8), "8"}, + {"uint16", uint16(16), "16"}, + {"uint32", uint32(32), "32"}, + {"uint64", uint64(64), "64"}, + + // float types + {"float32", float32(3.14), "3.14"}, + {"float64", 3.14159, "3.14159"}, + + // boolean + {"bool_true", true, "true"}, + {"bool_false", false, "false"}, + + // special types + {"time", now, now.String()}, + {"address", addr, string(addr)}, + {"bytes", []byte("hello"), "hello"}, + {"stringer", stringer, "test:hello"}, + + // slices + {"empty_slice", []string{}, "[]"}, + {"string_slice", []string{"a", "b"}, "[a b]"}, + {"int_slice", []int{1, 2}, "[1 2]"}, + {"int32_slice", []int32{1, 2}, "[1 2]"}, + {"int64_slice", []int64{1, 2}, "[1 2]"}, + {"float32_slice", []float32{1.1, 2.2}, "[1.1 2.2]"}, + {"float64_slice", []float64{1.1, 2.2}, "[1.1 2.2]"}, + {"bytes_slice", [][]byte{[]byte("a"), []byte("b")}, "[a b]"}, + {"time_slice", []time.Time{now, now}, "[" + now.String() + " " + now.String() + "]"}, + {"address_slice", []std.Address{addr, addr}, "[" + string(addr) + " " + string(addr) + "]"}, + {"interface_slice", []interface{}{1, "a", true}, "[1 a true]"}, + + // empty slices + {"empty_string_slice", []string{}, "[]"}, + {"empty_int_slice", []int{}, "[]"}, + {"empty_int32_slice", []int32{}, "[]"}, + {"empty_int64_slice", []int64{}, "[]"}, + {"empty_float32_slice", []float32{}, "[]"}, + {"empty_float64_slice", []float64{}, "[]"}, + {"empty_bytes_slice", [][]byte{}, "[]"}, + {"empty_time_slice", []time.Time{}, "[]"}, + {"empty_address_slice", []std.Address{}, "[]"}, + {"empty_interface_slice", []interface{}{}, "[]"}, + + // maps + {"empty_string_map", map[string]string{}, "map[]"}, + {"string_map", map[string]string{"a": "1", "b": "2"}, "map[a:1 b:2]"}, + {"empty_interface_map", map[string]interface{}{}, "map[]"}, + {"interface_map", map[string]interface{}{"a": 1, "b": "2"}, "map[a:1 b:2]"}, + + // edge cases + {"empty_bytes", []byte{}, ""}, + {"nil_interface", interface{}(nil), ""}, + {"empty_struct", struct{}{}, "{}"}, + {"unknown_type", struct{ foo string }{}, ""}, + + // pointer types + {"nil_string_ptr", (*string)(nil), ""}, + {"string_ptr", &str, "hello"}, + {"nil_int_ptr", (*int)(nil), ""}, + {"int_ptr", &num, "42"}, + {"nil_bool_ptr", (*bool)(nil), ""}, + {"bool_ptr", &b, "true"}, + // {"nil_time_ptr", (*time.Time)(nil), ""}, // TODO: fix this + {"time_ptr", &now, now.String()}, + // {"nil_address_ptr", (*std.Address)(nil), ""}, // TODO: fix this + {"address_ptr", &addr, string(addr)}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := ToString(tt.input) + if got != tt.expected { + t.Errorf("%s: ToString(%v) = %q, want %q", tt.name, tt.input, got, tt.expected) + } + }) + } +} + +func TestToBool(t *testing.T) { + str := "true" + num := 42 + b := true + now := time.Now() + addr := std.Address("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5") + zero := 0 + empty := "" + falseVal := false + + type testCase struct { + name string + input interface{} + expected bool + } + + tests := []testCase{ + // basic types + {"true", true, true}, + {"false", false, false}, + {"nil", nil, false}, + + // strings + {"empty_string", "", false}, + {"zero_string", "0", false}, + {"false_string", "false", false}, + {"f_string", "f", false}, + {"no_string", "no", false}, + {"n_string", "n", false}, + {"off_string", "off", false}, + {"space_string", " ", false}, + {"true_string", "true", true}, + {"yes_string", "yes", true}, + {"random_string", "hello", true}, + + // numbers + {"zero_int", 0, false}, + {"positive_int", 1, true}, + {"negative_int", -1, true}, + {"zero_float", 0.0, false}, + {"positive_float", 0.1, true}, + {"negative_float", -0.1, true}, + + // special types + {"empty_bytes", []byte{}, false}, + {"non_empty_bytes", []byte{1}, true}, + /*{"zero_time", time.Time{}, false},*/ // TODO: fix this + {"empty_address", std.Address(""), false}, + + // slices + {"empty_slice", []string{}, false}, + {"non_empty_slice", []string{"a"}, true}, + + // maps + {"empty_map", map[string]string{}, false}, + {"non_empty_map", map[string]string{"a": "b"}, true}, + + // pointer types + {"nil_bool_ptr", (*bool)(nil), false}, + {"true_ptr", &b, true}, + {"false_ptr", &falseVal, false}, + {"nil_string_ptr", (*string)(nil), false}, + {"string_ptr", &str, true}, + {"empty_string_ptr", &empty, false}, + {"nil_int_ptr", (*int)(nil), false}, + {"int_ptr", &num, true}, + {"zero_int_ptr", &zero, false}, + // {"nil_time_ptr", (*time.Time)(nil), false}, // TODO: fix this + {"time_ptr", &now, true}, + // {"nil_address_ptr", (*std.Address)(nil), false}, // TODO: fix this + {"address_ptr", &addr, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := ToBool(tt.input) + if got != tt.expected { + t.Errorf("%s: ToBool(%v) = %v, want %v", tt.name, tt.input, got, tt.expected) + } + }) + } +} + +func TestIsZero(t *testing.T) { + str := "hello" + num := 42 + b := true + now := time.Now() + addr := std.Address("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5") + zero := 0 + empty := "" + falseVal := false + + type testCase struct { + name string + input interface{} + expected bool + } + + tests := []testCase{ + // basic types + {"true", true, false}, + {"false", false, true}, + {"nil", nil, true}, + + // strings + {"empty_string", "", true}, + {"non_empty_string", "hello", false}, + + // numbers + {"zero_int", 0, true}, + {"non_zero_int", 1, false}, + {"zero_float", 0.0, true}, + {"non_zero_float", 0.1, false}, + + // special types + {"empty_bytes", []byte{}, true}, + {"non_empty_bytes", []byte{1}, false}, + /*{"zero_time", time.Time{}, true},*/ // TODO: fix this + {"empty_address", std.Address(""), true}, + + // slices + {"empty_slice", []string{}, true}, + {"non_empty_slice", []string{"a"}, false}, + + // maps + {"empty_map", map[string]string{}, true}, + {"non_empty_map", map[string]string{"a": "b"}, false}, + + // pointer types + {"nil_bool_ptr", (*bool)(nil), true}, + {"false_ptr", &falseVal, true}, + {"true_ptr", &b, false}, + {"nil_string_ptr", (*string)(nil), true}, + {"empty_string_ptr", &empty, true}, + {"string_ptr", &str, false}, + {"nil_int_ptr", (*int)(nil), true}, + {"zero_int_ptr", &zero, true}, + {"int_ptr", &num, false}, + // {"nil_time_ptr", (*time.Time)(nil), true}, // TODO: fix this + {"time_ptr", &now, false}, + // {"nil_address_ptr", (*std.Address)(nil), true}, // TODO: fix this + {"address_ptr", &addr, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := IsZero(tt.input) + if got != tt.expected { + t.Errorf("%s: IsZero(%v) = %v, want %v", tt.name, tt.input, got, tt.expected) + } + }) + } +} + +func TestToInterfaceSlice(t *testing.T) { + now := time.Now() + addr := std.Address("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5") + str := testStringer{value: "hello"} + + tests := []struct { + name string + input interface{} + expected []interface{} + compare func([]interface{}, []interface{}) bool + }{ + { + name: "nil", + input: nil, + expected: nil, + compare: compareNil, + }, + { + name: "empty_interface_slice", + input: []interface{}{}, + expected: []interface{}{}, + compare: compareEmpty, + }, + { + name: "interface_slice", + input: []interface{}{1, "two", true}, + expected: []interface{}{1, "two", true}, + compare: compareInterfaces, + }, + { + name: "string_slice", + input: []string{"a", "b", "c"}, + expected: []interface{}{"a", "b", "c"}, + compare: compareStrings, + }, + { + name: "int_slice", + input: []int{1, 2, 3}, + expected: []interface{}{1, 2, 3}, + compare: compareInts, + }, + { + name: "int32_slice", + input: []int32{1, 2, 3}, + expected: []interface{}{int32(1), int32(2), int32(3)}, + compare: compareInt32s, + }, + { + name: "int64_slice", + input: []int64{1, 2, 3}, + expected: []interface{}{int64(1), int64(2), int64(3)}, + compare: compareInt64s, + }, + { + name: "float32_slice", + input: []float32{1.1, 2.2, 3.3}, + expected: []interface{}{float32(1.1), float32(2.2), float32(3.3)}, + compare: compareFloat32s, + }, + { + name: "float64_slice", + input: []float64{1.1, 2.2, 3.3}, + expected: []interface{}{1.1, 2.2, 3.3}, + compare: compareFloat64s, + }, + { + name: "bool_slice", + input: []bool{true, false, true}, + expected: []interface{}{true, false, true}, + compare: compareBools, + }, + /* { + name: "time_slice", + input: []time.Time{now}, + expected: []interface{}{now}, + compare: compareTimes, + }, */ // TODO: fix this + /* { + name: "address_slice", + input: []std.Address{addr}, + expected: []interface{}{addr}, + compare: compareAddresses, + },*/ // TODO: fix this + /* { + name: "bytes_slice", + input: [][]byte{[]byte("hello"), []byte("world")}, + expected: []interface{}{[]byte("hello"), []byte("world")}, + compare: compareBytes, + },*/ // TODO: fix this + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := ToInterfaceSlice(tt.input) + if !tt.compare(got, tt.expected) { + t.Errorf("ToInterfaceSlice() = %v, want %v", got, tt.expected) + } + }) + } +} + +func compareNil(a, b []interface{}) bool { + return a == nil && b == nil +} + +func compareEmpty(a, b []interface{}) bool { + return len(a) == 0 && len(b) == 0 +} + +func compareInterfaces(a, b []interface{}) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +func compareStrings(a, b []interface{}) bool { + if len(a) != len(b) { + return false + } + for i := range a { + as, ok1 := a[i].(string) + bs, ok2 := b[i].(string) + if !ok1 || !ok2 || as != bs { + return false + } + } + return true +} + +func compareInts(a, b []interface{}) bool { + if len(a) != len(b) { + return false + } + for i := range a { + ai, ok1 := a[i].(int) + bi, ok2 := b[i].(int) + if !ok1 || !ok2 || ai != bi { + return false + } + } + return true +} + +func compareInt32s(a, b []interface{}) bool { + if len(a) != len(b) { + return false + } + for i := range a { + ai, ok1 := a[i].(int32) + bi, ok2 := b[i].(int32) + if !ok1 || !ok2 || ai != bi { + return false + } + } + return true +} + +func compareInt64s(a, b []interface{}) bool { + if len(a) != len(b) { + return false + } + for i := range a { + ai, ok1 := a[i].(int64) + bi, ok2 := b[i].(int64) + if !ok1 || !ok2 || ai != bi { + return false + } + } + return true +} + +func compareFloat32s(a, b []interface{}) bool { + if len(a) != len(b) { + return false + } + for i := range a { + ai, ok1 := a[i].(float32) + bi, ok2 := b[i].(float32) + if !ok1 || !ok2 || ai != bi { + return false + } + } + return true +} + +func compareFloat64s(a, b []interface{}) bool { + if len(a) != len(b) { + return false + } + for i := range a { + ai, ok1 := a[i].(float64) + bi, ok2 := b[i].(float64) + if !ok1 || !ok2 || ai != bi { + return false + } + } + return true +} + +func compareBools(a, b []interface{}) bool { + if len(a) != len(b) { + return false + } + for i := range a { + ab, ok1 := a[i].(bool) + bb, ok2 := b[i].(bool) + if !ok1 || !ok2 || ab != bb { + return false + } + } + return true +} + +func compareTimes(a, b []interface{}) bool { + if len(a) != len(b) { + return false + } + for i := range a { + at, ok1 := a[i].(time.Time) + bt, ok2 := b[i].(time.Time) + if !ok1 || !ok2 || !at.Equal(bt) { + return false + } + } + return true +} + +func compareAddresses(a, b []interface{}) bool { + if len(a) != len(b) { + return false + } + for i := range a { + aa, ok1 := a[i].(std.Address) + ba, ok2 := b[i].(std.Address) + if !ok1 || !ok2 || aa != ba { + return false + } + } + return true +} + +func compareBytes(a, b []interface{}) bool { + if len(a) != len(b) { + return false + } + for i := range a { + ab, ok1 := a[i].([]byte) + bb, ok2 := b[i].([]byte) + if !ok1 || !ok2 || string(ab) != string(bb) { + return false + } + } + return true +} + +// compareStringInterfaceMaps compares two map[string]interface{} for equality +func compareStringInterfaceMaps(a, b map[string]interface{}) bool { + if len(a) != len(b) { + return false + } + for k, v1 := range a { + v2, ok := b[k] + if !ok { + return false + } + // Compare values based on their type + switch val1 := v1.(type) { + case string: + val2, ok := v2.(string) + if !ok || val1 != val2 { + return false + } + case int: + val2, ok := v2.(int) + if !ok || val1 != val2 { + return false + } + case float64: + val2, ok := v2.(float64) + if !ok || val1 != val2 { + return false + } + case bool: + val2, ok := v2.(bool) + if !ok || val1 != val2 { + return false + } + case []interface{}: + val2, ok := v2.([]interface{}) + if !ok || len(val1) != len(val2) { + return false + } + for i := range val1 { + if val1[i] != val2[i] { + return false + } + } + case map[string]interface{}: + val2, ok := v2.(map[string]interface{}) + if !ok || !compareStringInterfaceMaps(val1, val2) { + return false + } + default: + return false + } + } + return true +} + +func TestToMapStringInterface(t *testing.T) { + tests := []struct { + name string + input interface{} + expected map[string]interface{} + wantErr bool + }{ + { + name: "map[string]interface{}", + input: map[string]interface{}{ + "key1": "value1", + "key2": 42, + }, + expected: map[string]interface{}{ + "key1": "value1", + "key2": 42, + }, + wantErr: false, + }, + { + name: "map[string]string", + input: map[string]string{ + "key1": "value1", + "key2": "value2", + }, + expected: map[string]interface{}{ + "key1": "value1", + "key2": "value2", + }, + wantErr: false, + }, + { + name: "map[string]int", + input: map[string]int{ + "key1": 1, + "key2": 2, + }, + expected: map[string]interface{}{ + "key1": 1, + "key2": 2, + }, + wantErr: false, + }, + { + name: "map[string]float64", + input: map[string]float64{ + "key1": 1.1, + "key2": 2.2, + }, + expected: map[string]interface{}{ + "key1": 1.1, + "key2": 2.2, + }, + wantErr: false, + }, + { + name: "map[string]bool", + input: map[string]bool{ + "key1": true, + "key2": false, + }, + expected: map[string]interface{}{ + "key1": true, + "key2": false, + }, + wantErr: false, + }, + { + name: "map[string][]string", + input: map[string][]string{ + "key1": {"a", "b"}, + "key2": {"c", "d"}, + }, + expected: map[string]interface{}{ + "key1": []interface{}{"a", "b"}, + "key2": []interface{}{"c", "d"}, + }, + wantErr: false, + }, + { + name: "nested map[string]map[string]string", + input: map[string]map[string]string{ + "key1": {"nested1": "value1"}, + "key2": {"nested2": "value2"}, + }, + expected: map[string]interface{}{ + "key1": map[string]interface{}{"nested1": "value1"}, + "key2": map[string]interface{}{"nested2": "value2"}, + }, + wantErr: false, + }, + { + name: "unsupported type", + input: 42, // not a map + expected: nil, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ToMapStringInterface(tt.input) + if (err != nil) != tt.wantErr { + t.Errorf("ToMapStringInterface() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !tt.wantErr { + if !compareStringInterfaceMaps(got, tt.expected) { + t.Errorf("ToMapStringInterface() = %v, expected %v", got, tt.expected) + } + } + }) + } +} + +// Test error messages +func TestToMapStringInterfaceErrors(t *testing.T) { + _, err := ToMapStringInterface(42) + if err == nil || !strings.Contains(err.Error(), "unsupported map type") { + t.Errorf("Expected error containing 'unsupported map type', got %v", err) + } +} + +// compareIntInterfaceMaps compares two map[int]interface{} for equality +func compareIntInterfaceMaps(a, b map[int]interface{}) bool { + if len(a) != len(b) { + return false + } + for k, v1 := range a { + v2, ok := b[k] + if !ok { + return false + } + // Compare values based on their type + switch val1 := v1.(type) { + case string: + val2, ok := v2.(string) + if !ok || val1 != val2 { + return false + } + case int: + val2, ok := v2.(int) + if !ok || val1 != val2 { + return false + } + case float64: + val2, ok := v2.(float64) + if !ok || val1 != val2 { + return false + } + case bool: + val2, ok := v2.(bool) + if !ok || val1 != val2 { + return false + } + case []interface{}: + val2, ok := v2.([]interface{}) + if !ok || len(val1) != len(val2) { + return false + } + for i := range val1 { + if val1[i] != val2[i] { + return false + } + } + case map[string]interface{}: + val2, ok := v2.(map[string]interface{}) + if !ok || !compareStringInterfaceMaps(val1, val2) { + return false + } + default: + return false + } + } + return true +} + +func TestToMapIntInterface(t *testing.T) { + tests := []struct { + name string + input interface{} + expected map[int]interface{} + wantErr bool + }{ + { + name: "map[int]interface{}", + input: map[int]interface{}{ + 1: "value1", + 2: 42, + }, + expected: map[int]interface{}{ + 1: "value1", + 2: 42, + }, + wantErr: false, + }, + { + name: "map[int]string", + input: map[int]string{ + 1: "value1", + 2: "value2", + }, + expected: map[int]interface{}{ + 1: "value1", + 2: "value2", + }, + wantErr: false, + }, + { + name: "map[int]int", + input: map[int]int{ + 1: 10, + 2: 20, + }, + expected: map[int]interface{}{ + 1: 10, + 2: 20, + }, + wantErr: false, + }, + { + name: "map[int]float64", + input: map[int]float64{ + 1: 1.1, + 2: 2.2, + }, + expected: map[int]interface{}{ + 1: 1.1, + 2: 2.2, + }, + wantErr: false, + }, + { + name: "map[int]bool", + input: map[int]bool{ + 1: true, + 2: false, + }, + expected: map[int]interface{}{ + 1: true, + 2: false, + }, + wantErr: false, + }, + { + name: "map[int][]string", + input: map[int][]string{ + 1: {"a", "b"}, + 2: {"c", "d"}, + }, + expected: map[int]interface{}{ + 1: []interface{}{"a", "b"}, + 2: []interface{}{"c", "d"}, + }, + wantErr: false, + }, + { + name: "map[int]map[string]interface{}", + input: map[int]map[string]interface{}{ + 1: {"nested1": "value1"}, + 2: {"nested2": "value2"}, + }, + expected: map[int]interface{}{ + 1: map[string]interface{}{"nested1": "value1"}, + 2: map[string]interface{}{"nested2": "value2"}, + }, + wantErr: false, + }, + { + name: "unsupported type", + input: 42, // not a map + expected: nil, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ToMapIntInterface(tt.input) + if (err != nil) != tt.wantErr { + t.Errorf("ToMapIntInterface() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !tt.wantErr { + if !compareIntInterfaceMaps(got, tt.expected) { + t.Errorf("ToMapIntInterface() = %v, expected %v", got, tt.expected) + } + } + }) + } +} + +func TestToStringSlice(t *testing.T) { + tests := []struct { + name string + input interface{} + expected []string + }{ + { + name: "nil input", + input: nil, + expected: nil, + }, + { + name: "empty slice", + input: []string{}, + expected: []string{}, + }, + { + name: "string slice", + input: []string{"a", "b", "c"}, + expected: []string{"a", "b", "c"}, + }, + { + name: "int slice", + input: []int{1, 2, 3}, + expected: []string{"1", "2", "3"}, + }, + { + name: "int32 slice", + input: []int32{1, 2, 3}, + expected: []string{"1", "2", "3"}, + }, + { + name: "int64 slice", + input: []int64{1, 2, 3}, + expected: []string{"1", "2", "3"}, + }, + { + name: "uint slice", + input: []uint{1, 2, 3}, + expected: []string{"1", "2", "3"}, + }, + { + name: "uint8 slice", + input: []uint8{1, 2, 3}, + expected: []string{"1", "2", "3"}, + }, + { + name: "uint16 slice", + input: []uint16{1, 2, 3}, + expected: []string{"1", "2", "3"}, + }, + { + name: "uint32 slice", + input: []uint32{1, 2, 3}, + expected: []string{"1", "2", "3"}, + }, + { + name: "uint64 slice", + input: []uint64{1, 2, 3}, + expected: []string{"1", "2", "3"}, + }, + { + name: "float32 slice", + input: []float32{1.1, 2.2, 3.3}, + expected: []string{"1.1", "2.2", "3.3"}, + }, + { + name: "float64 slice", + input: []float64{1.1, 2.2, 3.3}, + expected: []string{"1.1", "2.2", "3.3"}, + }, + { + name: "bool slice", + input: []bool{true, false, true}, + expected: []string{"true", "false", "true"}, + }, + { + name: "[]byte slice", + input: [][]byte{[]byte("hello"), []byte("world")}, + expected: []string{"hello", "world"}, + }, + { + name: "interface slice", + input: []interface{}{1, "hello", true}, + expected: []string{"1", "hello", "true"}, + }, + { + name: "time slice", + input: []time.Time{time.Time{}, time.Time{}}, + expected: []string{"0001-01-01 00:00:00 +0000 UTC", "0001-01-01 00:00:00 +0000 UTC"}, + }, + { + name: "address slice", + input: []std.Address{"addr1", "addr2"}, + expected: []string{"addr1", "addr2"}, + }, + { + name: "non-slice input", + input: 42, + expected: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := ToStringSlice(tt.input) + if !slicesEqual(result, tt.expected) { + t.Errorf("ToStringSlice(%v) = %v, want %v", tt.input, result, tt.expected) + } + }) + } +} + +// Helper function to compare string slices +func slicesEqual(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +func TestToStringAdvanced(t *testing.T) { + tests := []struct { + name string + input interface{} + expected string + }{ + { + name: "slice with mixed basic types", + input: []interface{}{ + 42, + "hello", + true, + 3.14, + }, + expected: "[42 hello true 3.14]", + }, + { + name: "map with basic types", + input: map[string]interface{}{ + "int": 42, + "str": "hello", + "bool": true, + "float": 3.14, + }, + expected: "map[bool:true float:3.14 int:42 str:hello]", + }, + { + name: "mixed types map", + input: map[interface{}]interface{}{ + 42: "number", + "string": 123, + true: []int{1, 2, 3}, + struct{}{}: "empty", + }, + expected: "map[42:number string:123 true:[1 2 3] {}:empty]", + }, + { + name: "nested maps", + input: map[string]interface{}{ + "a": map[string]int{ + "x": 1, + "y": 2, + }, + "b": []interface{}{1, "two", true}, + }, + expected: "map[a:map[x:1 y:2] b:[1 two true]]", + }, + { + name: "empty struct", + input: struct{}{}, + expected: "{}", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := ToString(tt.input) + if result != tt.expected { + t.Errorf("\nToString(%v) =\n%v\nwant:\n%v", tt.input, result, tt.expected) + } + }) + } +} diff --git a/examples/gno.land/p/moul/ulist/gno.mod b/examples/gno.land/p/moul/ulist/gno.mod new file mode 100644 index 00000000000..077f8c556f3 --- /dev/null +++ b/examples/gno.land/p/moul/ulist/gno.mod @@ -0,0 +1 @@ +module gno.land/p/moul/ulist diff --git a/examples/gno.land/p/moul/ulist/ulist.gno b/examples/gno.land/p/moul/ulist/ulist.gno new file mode 100644 index 00000000000..507a02a4e45 --- /dev/null +++ b/examples/gno.land/p/moul/ulist/ulist.gno @@ -0,0 +1,437 @@ +// Package ulist provides an append-only list implementation using a binary tree structure, +// optimized for scenarios requiring sequential inserts with auto-incrementing indices. +// +// The implementation uses a binary tree where new elements are added by following a path +// determined by the binary representation of the index. This provides automatic balancing +// for append operations without requiring any balancing logic. +// +// Unlike the AVL tree-based list implementation (p/demo/avl/list), ulist is specifically +// designed for append-only operations and does not require rebalancing. This makes it more +// efficient for sequential inserts but less flexible for general-purpose list operations. +// +// Key differences from AVL list: +// * Append-only design (no arbitrary inserts) +// * No tree rebalancing needed +// * Simpler implementation +// * More memory efficient for sequential operations +// * Less flexible than AVL (no arbitrary inserts/reordering) +// +// Key characteristics: +// * O(log n) append and access operations +// * Perfect balance for power-of-2 sizes +// * No balancing needed +// * Memory efficient +// * Natural support for range queries +// * Support for soft deletion of elements +// * Forward and reverse iteration capabilities +// * Offset-based iteration with count control +package ulist + +// TODO: Use this ulist in moul/collection for the primary index. +// TODO: Benchmarks. + +import ( + "errors" +) + +// List represents an append-only binary tree list +type List struct { + root *treeNode + totalSize int + activeSize int +} + +// Entry represents a key-value pair in the list, where Index is the position +// and Value is the stored data +type Entry struct { + Index int + Value interface{} +} + +// treeNode represents a node in the binary tree +type treeNode struct { + data interface{} + left *treeNode + right *treeNode +} + +// Error variables +var ( + ErrOutOfBounds = errors.New("index out of bounds") + ErrDeleted = errors.New("element already deleted") +) + +// New creates a new empty List instance +func New() *List { + return &List{} +} + +// Append adds one or more values to the end of the list. +// Values are added sequentially, and the list grows automatically. +func (l *List) Append(values ...interface{}) { + for _, value := range values { + index := l.totalSize + node := l.findNode(index, true) + node.data = value + l.totalSize++ + l.activeSize++ + } +} + +// Get retrieves the value at the specified index. +// Returns nil if the index is out of bounds or if the element was deleted. +func (l *List) Get(index int) interface{} { + node := l.findNode(index, false) + if node == nil { + return nil + } + return node.data +} + +// Delete marks the elements at the specified indices as deleted. +// Returns ErrOutOfBounds if any index is invalid or ErrDeleted if +// the element was already deleted. +func (l *List) Delete(indices ...int) error { + if len(indices) == 0 { + return nil + } + if l == nil || l.totalSize == 0 { + return ErrOutOfBounds + } + + for _, index := range indices { + if index < 0 || index >= l.totalSize { + return ErrOutOfBounds + } + + node := l.findNode(index, false) + if node == nil || node.data == nil { + return ErrDeleted + } + node.data = nil + l.activeSize-- + } + + return nil +} + +// Set updates or restores a value at the specified index if within bounds +// Returns ErrOutOfBounds if the index is invalid +func (l *List) Set(index int, value interface{}) error { + if l == nil || index < 0 || index >= l.totalSize { + return ErrOutOfBounds + } + + node := l.findNode(index, false) + if node == nil { + return ErrOutOfBounds + } + + // If this is restoring a deleted element + if value != nil && node.data == nil { + l.activeSize++ + } + + // If this is deleting an element + if value == nil && node.data != nil { + l.activeSize-- + } + + node.data = value + return nil +} + +// Size returns the number of active (non-deleted) elements in the list +func (l *List) Size() int { + if l == nil { + return 0 + } + return l.activeSize +} + +// TotalSize returns the total number of elements ever added to the list, +// including deleted elements +func (l *List) TotalSize() int { + if l == nil { + return 0 + } + return l.totalSize +} + +// IterCbFn is a callback function type used in iteration methods. +// Return true to stop iteration, false to continue. +type IterCbFn func(index int, value interface{}) bool + +// Iterator performs iteration between start and end indices, calling cb for each entry. +// If start > end, iteration is performed in reverse order. +// Returns true if iteration was stopped early by the callback returning true. +// Skips deleted elements. +func (l *List) Iterator(start, end int, cb IterCbFn) bool { + // For empty list or invalid range + if l == nil || l.totalSize == 0 { + return false + } + if start < 0 && end < 0 { + return false + } + if start >= l.totalSize && end >= l.totalSize { + return false + } + + // Normalize indices + if start < 0 { + start = 0 + } + if end < 0 { + end = 0 + } + if end >= l.totalSize { + end = l.totalSize - 1 + } + if start >= l.totalSize { + start = l.totalSize - 1 + } + + // Handle reverse iteration + if start > end { + for i := start; i >= end; i-- { + val := l.Get(i) + if val != nil { + if cb(i, val) { + return true + } + } + } + return false + } + + // Handle forward iteration + for i := start; i <= end; i++ { + val := l.Get(i) + if val != nil { + if cb(i, val) { + return true + } + } + } + return false +} + +// IteratorByOffset performs iteration starting from offset for count elements. +// If count is positive, iterates forward; if negative, iterates backward. +// The iteration stops after abs(count) elements or when reaching list bounds. +// Skips deleted elements. +func (l *List) IteratorByOffset(offset int, count int, cb IterCbFn) bool { + if count == 0 || l == nil || l.totalSize == 0 { + return false + } + + // Normalize offset + if offset < 0 { + offset = 0 + } + if offset >= l.totalSize { + offset = l.totalSize - 1 + } + + // Determine end based on count direction + var end int + if count > 0 { + end = l.totalSize - 1 + } else { + end = 0 + } + + wrapperReturned := false + + // Wrap the callback to limit iterations + remaining := abs(count) + wrapper := func(index int, value interface{}) bool { + if remaining <= 0 { + wrapperReturned = true + return true + } + remaining-- + return cb(index, value) + } + ret := l.Iterator(offset, end, wrapper) + if wrapperReturned { + return false + } + return ret +} + +// abs returns the absolute value of x +func abs(x int) int { + if x < 0 { + return -x + } + return x +} + +// findNode locates or creates a node at the given index in the binary tree. +// The tree is structured such that the path to a node is determined by the binary +// representation of the index. For example, a tree with 15 elements would look like: +// +// 0 +// / \ +// 1 2 +// / \ / \ +// 3 4 5 6 +// / \ / \ / \ / \ +// 7 8 9 10 11 12 13 14 +// +// To find index 13 (binary 1101): +// 1. Start at root (0) +// 2. Calculate bits needed (4 bits for index 13) +// 3. Skip the highest bit position and start from bits-2 +// 4. Read bits from left to right: +// - 1 -> go right to 2 +// - 1 -> go right to 6 +// - 0 -> go left to 13 +// +// Special cases: +// - Index 0 always returns the root node +// - For create=true, missing nodes are created along the path +// - For create=false, returns nil if any node is missing +func (l *List) findNode(index int, create bool) *treeNode { + // For read operations, check bounds strictly + if !create && (l == nil || index < 0 || index >= l.totalSize) { + return nil + } + + // For create operations, allow index == totalSize for append + if create && (l == nil || index < 0 || index > l.totalSize) { + return nil + } + + // Initialize root if needed + if l.root == nil { + if !create { + return nil + } + l.root = &treeNode{} + return l.root + } + + node := l.root + + // Special case for root node + if index == 0 { + return node + } + + // Calculate the number of bits needed (inline highestBit logic) + bits := 0 + n := index + 1 + for n > 0 { + n >>= 1 + bits++ + } + + // Start from the second highest bit + for level := bits - 2; level >= 0; level-- { + bit := (index & (1 << uint(level))) != 0 + + if bit { + if node.right == nil { + if !create { + return nil + } + node.right = &treeNode{} + } + node = node.right + } else { + if node.left == nil { + if !create { + return nil + } + node.left = &treeNode{} + } + node = node.left + } + } + + return node +} + +// MustDelete deletes elements at the specified indices. +// Panics if any index is invalid or if any element was already deleted. +func (l *List) MustDelete(indices ...int) { + if err := l.Delete(indices...); err != nil { + panic(err) + } +} + +// MustGet retrieves the value at the specified index. +// Panics if the index is out of bounds or if the element was deleted. +func (l *List) MustGet(index int) interface{} { + if l == nil || index < 0 || index >= l.totalSize { + panic(ErrOutOfBounds) + } + value := l.Get(index) + if value == nil { + panic(ErrDeleted) + } + return value +} + +// MustSet updates or restores a value at the specified index. +// Panics if the index is out of bounds. +func (l *List) MustSet(index int, value interface{}) { + if err := l.Set(index, value); err != nil { + panic(err) + } +} + +// GetRange returns a slice of Entry containing elements between start and end indices. +// If start > end, elements are returned in reverse order. +// Deleted elements are skipped. +func (l *List) GetRange(start, end int) []Entry { + var entries []Entry + l.Iterator(start, end, func(index int, value interface{}) bool { + entries = append(entries, Entry{Index: index, Value: value}) + return false + }) + return entries +} + +// GetByOffset returns a slice of Entry starting from offset for count elements. +// If count is positive, returns elements forward; if negative, returns elements backward. +// The operation stops after abs(count) elements or when reaching list bounds. +// Deleted elements are skipped. +func (l *List) GetByOffset(offset int, count int) []Entry { + var entries []Entry + l.IteratorByOffset(offset, count, func(index int, value interface{}) bool { + entries = append(entries, Entry{Index: index, Value: value}) + return false + }) + return entries +} + +// IList defines the interface for an ulist.List compatible structure. +type IList interface { + // Basic operations + Append(values ...interface{}) + Get(index int) interface{} + Delete(indices ...int) error + Size() int + TotalSize() int + Set(index int, value interface{}) error + + // Must variants that panic instead of returning errors + MustDelete(indices ...int) + MustGet(index int) interface{} + MustSet(index int, value interface{}) + + // Range operations + GetRange(start, end int) []Entry + GetByOffset(offset int, count int) []Entry + + // Iterator operations + Iterator(start, end int, cb IterCbFn) bool + IteratorByOffset(offset int, count int, cb IterCbFn) bool +} + +// Verify that List implements IList +var _ IList = (*List)(nil) diff --git a/examples/gno.land/p/moul/ulist/ulist_test.gno b/examples/gno.land/p/moul/ulist/ulist_test.gno new file mode 100644 index 00000000000..f098731a7db --- /dev/null +++ b/examples/gno.land/p/moul/ulist/ulist_test.gno @@ -0,0 +1,1422 @@ +package ulist + +import ( + "testing" + + "gno.land/p/demo/uassert" + "gno.land/p/demo/ufmt" + "gno.land/p/moul/typeutil" +) + +func TestNew(t *testing.T) { + l := New() + uassert.Equal(t, 0, l.Size()) + uassert.Equal(t, 0, l.TotalSize()) +} + +func TestListAppendAndGet(t *testing.T) { + tests := []struct { + name string + setup func() *List + index int + expected interface{} + }{ + { + name: "empty list", + setup: func() *List { + return New() + }, + index: 0, + expected: nil, + }, + { + name: "single append and get", + setup: func() *List { + l := New() + l.Append(42) + return l + }, + index: 0, + expected: 42, + }, + { + name: "multiple appends and get first", + setup: func() *List { + l := New() + l.Append(1) + l.Append(2) + l.Append(3) + return l + }, + index: 0, + expected: 1, + }, + { + name: "multiple appends and get last", + setup: func() *List { + l := New() + l.Append(1) + l.Append(2) + l.Append(3) + return l + }, + index: 2, + expected: 3, + }, + { + name: "get with invalid index", + setup: func() *List { + l := New() + l.Append(1) + return l + }, + index: 1, + expected: nil, + }, + { + name: "31 items get first", + setup: func() *List { + l := New() + for i := 0; i < 31; i++ { + l.Append(i) + } + return l + }, + index: 0, + expected: 0, + }, + { + name: "31 items get last", + setup: func() *List { + l := New() + for i := 0; i < 31; i++ { + l.Append(i) + } + return l + }, + index: 30, + expected: 30, + }, + { + name: "31 items get middle", + setup: func() *List { + l := New() + for i := 0; i < 31; i++ { + l.Append(i) + } + return l + }, + index: 15, + expected: 15, + }, + { + name: "values around power of 2 boundary", + setup: func() *List { + l := New() + for i := 0; i < 18; i++ { + l.Append(i) + } + return l + }, + index: 15, + expected: 15, + }, + { + name: "values at power of 2", + setup: func() *List { + l := New() + for i := 0; i < 18; i++ { + l.Append(i) + } + return l + }, + index: 16, + expected: 16, + }, + { + name: "values after power of 2", + setup: func() *List { + l := New() + for i := 0; i < 18; i++ { + l.Append(i) + } + return l + }, + index: 17, + expected: 17, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + l := tt.setup() + got := l.Get(tt.index) + if got != tt.expected { + t.Errorf("List.Get() = %v, want %v", got, tt.expected) + } + }) + } +} + +// generateSequence creates a slice of integers from 0 to n-1 +func generateSequence(n int) []interface{} { + result := make([]interface{}, n) + for i := 0; i < n; i++ { + result[i] = i + } + return result +} + +func TestListDelete(t *testing.T) { + tests := []struct { + name string + setup func() *List + deleteIndices []int + expectedErr error + expectedSize int + }{ + { + name: "delete single element", + setup: func() *List { + l := New() + l.Append(1, 2, 3) + return l + }, + deleteIndices: []int{1}, + expectedErr: nil, + expectedSize: 2, + }, + { + name: "delete multiple elements", + setup: func() *List { + l := New() + l.Append(1, 2, 3, 4, 5) + return l + }, + deleteIndices: []int{0, 2, 4}, + expectedErr: nil, + expectedSize: 2, + }, + { + name: "delete with negative index", + setup: func() *List { + l := New() + l.Append(1) + return l + }, + deleteIndices: []int{-1}, + expectedErr: ErrOutOfBounds, + expectedSize: 1, + }, + { + name: "delete beyond size", + setup: func() *List { + l := New() + l.Append(1) + return l + }, + deleteIndices: []int{1}, + expectedErr: ErrOutOfBounds, + expectedSize: 1, + }, + { + name: "delete already deleted element", + setup: func() *List { + l := New() + l.Append(1) + l.Delete(0) + return l + }, + deleteIndices: []int{0}, + expectedErr: ErrDeleted, + expectedSize: 0, + }, + { + name: "delete multiple elements in reverse", + setup: func() *List { + l := New() + l.Append(1, 2, 3, 4, 5) + return l + }, + deleteIndices: []int{4, 2, 0}, + expectedErr: nil, + expectedSize: 2, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + l := tt.setup() + initialSize := l.Size() + err := l.Delete(tt.deleteIndices...) + if err != nil && tt.expectedErr != nil { + uassert.Equal(t, tt.expectedErr.Error(), err.Error()) + } else { + uassert.Equal(t, tt.expectedErr, err) + } + uassert.Equal(t, tt.expectedSize, l.Size(), + ufmt.Sprintf("Expected size %d after deleting %d elements from size %d, got %d", + tt.expectedSize, len(tt.deleteIndices), initialSize, l.Size())) + }) + } +} + +func TestListSizeAndTotalSize(t *testing.T) { + t.Run("empty list", func(t *testing.T) { + list := New() + uassert.Equal(t, 0, list.Size()) + uassert.Equal(t, 0, list.TotalSize()) + }) + + t.Run("list with elements", func(t *testing.T) { + list := New() + list.Append(1) + list.Append(2) + list.Append(3) + uassert.Equal(t, 3, list.Size()) + uassert.Equal(t, 3, list.TotalSize()) + }) + + t.Run("list with deleted elements", func(t *testing.T) { + list := New() + list.Append(1) + list.Append(2) + list.Append(3) + list.Delete(1) + uassert.Equal(t, 2, list.Size()) + uassert.Equal(t, 3, list.TotalSize()) + }) +} + +func TestIterator(t *testing.T) { + tests := []struct { + name string + values []interface{} + start int + end int + expected []Entry + wantStop bool + stopAfter int // stop after N elements, -1 for no stop + }{ + { + name: "empty list", + values: []interface{}{}, + start: 0, + end: 10, + expected: []Entry{}, + stopAfter: -1, + }, + { + name: "nil list", + values: nil, + start: 0, + end: 0, + expected: []Entry{}, + stopAfter: -1, + }, + { + name: "single element forward", + values: []interface{}{42}, + start: 0, + end: 0, + expected: []Entry{ + {Index: 0, Value: 42}, + }, + stopAfter: -1, + }, + { + name: "multiple elements forward", + values: []interface{}{1, 2, 3, 4, 5}, + start: 0, + end: 4, + expected: []Entry{ + {Index: 0, Value: 1}, + {Index: 1, Value: 2}, + {Index: 2, Value: 3}, + {Index: 3, Value: 4}, + {Index: 4, Value: 5}, + }, + stopAfter: -1, + }, + { + name: "multiple elements reverse", + values: []interface{}{1, 2, 3, 4, 5}, + start: 4, + end: 0, + expected: []Entry{ + {Index: 4, Value: 5}, + {Index: 3, Value: 4}, + {Index: 2, Value: 3}, + {Index: 1, Value: 2}, + {Index: 0, Value: 1}, + }, + stopAfter: -1, + }, + { + name: "partial range forward", + values: []interface{}{1, 2, 3, 4, 5}, + start: 1, + end: 3, + expected: []Entry{ + {Index: 1, Value: 2}, + {Index: 2, Value: 3}, + {Index: 3, Value: 4}, + }, + stopAfter: -1, + }, + { + name: "partial range reverse", + values: []interface{}{1, 2, 3, 4, 5}, + start: 3, + end: 1, + expected: []Entry{ + {Index: 3, Value: 4}, + {Index: 2, Value: 3}, + {Index: 1, Value: 2}, + }, + stopAfter: -1, + }, + { + name: "stop iteration early", + values: []interface{}{1, 2, 3, 4, 5}, + start: 0, + end: 4, + wantStop: true, + stopAfter: 2, + expected: []Entry{ + {Index: 0, Value: 1}, + {Index: 1, Value: 2}, + }, + }, + { + name: "negative start", + values: []interface{}{1, 2, 3}, + start: -1, + end: 2, + expected: []Entry{ + {Index: 0, Value: 1}, + {Index: 1, Value: 2}, + {Index: 2, Value: 3}, + }, + stopAfter: -1, + }, + { + name: "negative end", + values: []interface{}{1, 2, 3}, + start: 0, + end: -2, + expected: []Entry{ + {Index: 0, Value: 1}, + }, + stopAfter: -1, + }, + { + name: "start beyond size", + values: []interface{}{1, 2, 3}, + start: 5, + end: 6, + expected: []Entry{}, + stopAfter: -1, + }, + { + name: "end beyond size", + values: []interface{}{1, 2, 3}, + start: 0, + end: 5, + expected: []Entry{ + {Index: 0, Value: 1}, + {Index: 1, Value: 2}, + {Index: 2, Value: 3}, + }, + stopAfter: -1, + }, + { + name: "with deleted elements", + values: []interface{}{1, 2, nil, 4, 5}, + start: 0, + end: 4, + expected: []Entry{ + {Index: 0, Value: 1}, + {Index: 1, Value: 2}, + {Index: 3, Value: 4}, + {Index: 4, Value: 5}, + }, + stopAfter: -1, + }, + { + name: "with deleted elements reverse", + values: []interface{}{1, nil, 3, nil, 5}, + start: 4, + end: 0, + expected: []Entry{ + {Index: 4, Value: 5}, + {Index: 2, Value: 3}, + {Index: 0, Value: 1}, + }, + stopAfter: -1, + }, + { + name: "start equals end", + values: []interface{}{1, 2, 3}, + start: 1, + end: 1, + expected: []Entry{{Index: 1, Value: 2}}, + stopAfter: -1, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + list := New() + list.Append(tt.values...) + + var result []Entry + stopped := list.Iterator(tt.start, tt.end, func(index int, value interface{}) bool { + result = append(result, Entry{Index: index, Value: value}) + return tt.stopAfter >= 0 && len(result) >= tt.stopAfter + }) + + uassert.Equal(t, len(result), len(tt.expected), "comparing length") + + for i := range result { + uassert.Equal(t, result[i].Index, tt.expected[i].Index, "comparing index") + uassert.Equal(t, typeutil.ToString(result[i].Value), typeutil.ToString(tt.expected[i].Value), "comparing value") + } + + uassert.Equal(t, stopped, tt.wantStop, "comparing stopped") + }) + } +} + +func TestLargeListAppendGetAndDelete(t *testing.T) { + l := New() + size := 100 + + // Append values from 0 to 99 + for i := 0; i < size; i++ { + l.Append(i) + val := l.Get(i) + uassert.Equal(t, i, val) + } + + // Verify size + uassert.Equal(t, size, l.Size()) + uassert.Equal(t, size, l.TotalSize()) + + // Get and verify each value + for i := 0; i < size; i++ { + val := l.Get(i) + uassert.Equal(t, i, val) + } + + // Get and verify each value + for i := 0; i < size; i++ { + err := l.Delete(i) + uassert.Equal(t, nil, err) + } + + // Verify size + uassert.Equal(t, 0, l.Size()) + uassert.Equal(t, size, l.TotalSize()) + + // Get and verify each value + for i := 0; i < size; i++ { + val := l.Get(i) + uassert.Equal(t, nil, val) + } +} + +func TestEdgeCases(t *testing.T) { + tests := []struct { + name string + test func(t *testing.T) + }{ + { + name: "nil list operations", + test: func(t *testing.T) { + var l *List + uassert.Equal(t, 0, l.Size()) + uassert.Equal(t, 0, l.TotalSize()) + uassert.Equal(t, nil, l.Get(0)) + err := l.Delete(0) + uassert.Equal(t, ErrOutOfBounds.Error(), err.Error()) + }, + }, + { + name: "delete empty indices slice", + test: func(t *testing.T) { + l := New() + l.Append(1) + err := l.Delete() + uassert.Equal(t, nil, err) + uassert.Equal(t, 1, l.Size()) + }, + }, + { + name: "append nil values", + test: func(t *testing.T) { + l := New() + l.Append(nil, nil) + uassert.Equal(t, 2, l.Size()) + uassert.Equal(t, nil, l.Get(0)) + uassert.Equal(t, nil, l.Get(1)) + }, + }, + { + name: "delete same index multiple times", + test: func(t *testing.T) { + l := New() + l.Append(1, 2, 3) + err := l.Delete(1) + uassert.Equal(t, nil, err) + err = l.Delete(1) + uassert.Equal(t, ErrDeleted.Error(), err.Error()) + }, + }, + { + name: "iterator with all deleted elements", + test: func(t *testing.T) { + l := New() + l.Append(1, 2, 3) + l.Delete(0, 1, 2) + var count int + l.Iterator(0, 2, func(index int, value interface{}) bool { + count++ + return false + }) + uassert.Equal(t, 0, count) + }, + }, + { + name: "append after delete", + test: func(t *testing.T) { + l := New() + l.Append(1, 2) + l.Delete(1) + l.Append(3) + uassert.Equal(t, 2, l.Size()) + uassert.Equal(t, 3, l.TotalSize()) + uassert.Equal(t, 1, l.Get(0)) + uassert.Equal(t, nil, l.Get(1)) + uassert.Equal(t, 3, l.Get(2)) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.test(t) + }) + } +} + +func TestIteratorByOffset(t *testing.T) { + tests := []struct { + name string + values []interface{} + offset int + count int + expected []Entry + wantStop bool + }{ + { + name: "empty list", + values: []interface{}{}, + offset: 0, + count: 5, + expected: []Entry{}, + wantStop: false, + }, + { + name: "positive count forward iteration", + values: []interface{}{1, 2, 3, 4, 5}, + offset: 1, + count: 2, + expected: []Entry{ + {Index: 1, Value: 2}, + {Index: 2, Value: 3}, + }, + wantStop: false, + }, + { + name: "negative count backward iteration", + values: []interface{}{1, 2, 3, 4, 5}, + offset: 3, + count: -2, + expected: []Entry{ + {Index: 3, Value: 4}, + {Index: 2, Value: 3}, + }, + wantStop: false, + }, + { + name: "count exceeds available elements forward", + values: []interface{}{1, 2, 3}, + offset: 1, + count: 5, + expected: []Entry{ + {Index: 1, Value: 2}, + {Index: 2, Value: 3}, + }, + wantStop: false, + }, + { + name: "count exceeds available elements backward", + values: []interface{}{1, 2, 3}, + offset: 1, + count: -5, + expected: []Entry{ + {Index: 1, Value: 2}, + {Index: 0, Value: 1}, + }, + wantStop: false, + }, + { + name: "zero count", + values: []interface{}{1, 2, 3}, + offset: 0, + count: 0, + expected: []Entry{}, + wantStop: false, + }, + { + name: "negative offset", + values: []interface{}{1, 2, 3}, + offset: -1, + count: 2, + expected: []Entry{ + {Index: 0, Value: 1}, + {Index: 1, Value: 2}, + }, + wantStop: false, + }, + { + name: "offset beyond size", + values: []interface{}{1, 2, 3}, + offset: 5, + count: -2, + expected: []Entry{ + {Index: 2, Value: 3}, + {Index: 1, Value: 2}, + }, + wantStop: false, + }, + { + name: "with deleted elements", + values: []interface{}{1, nil, 3, nil, 5}, + offset: 0, + count: 3, + expected: []Entry{ + {Index: 0, Value: 1}, + {Index: 2, Value: 3}, + {Index: 4, Value: 5}, + }, + wantStop: false, + }, + { + name: "early stop in forward iteration", + values: []interface{}{1, 2, 3, 4, 5}, + offset: 0, + count: 5, + expected: []Entry{ + {Index: 0, Value: 1}, + {Index: 1, Value: 2}, + }, + wantStop: true, // The callback will return true after 2 elements + }, + { + name: "early stop in backward iteration", + values: []interface{}{1, 2, 3, 4, 5}, + offset: 4, + count: -5, + expected: []Entry{ + {Index: 4, Value: 5}, + {Index: 3, Value: 4}, + }, + wantStop: true, // The callback will return true after 2 elements + }, + { + name: "nil list", + values: nil, + offset: 0, + count: 5, + expected: []Entry{}, + wantStop: false, + }, + { + name: "single element forward", + values: []interface{}{1}, + offset: 0, + count: 5, + expected: []Entry{ + {Index: 0, Value: 1}, + }, + wantStop: false, + }, + { + name: "single element backward", + values: []interface{}{1}, + offset: 0, + count: -5, + expected: []Entry{ + {Index: 0, Value: 1}, + }, + wantStop: false, + }, + { + name: "all deleted elements", + values: []interface{}{nil, nil, nil}, + offset: 0, + count: 3, + expected: []Entry{}, + wantStop: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + list := New() + list.Append(tt.values...) + + var result []Entry + var cb IterCbFn + if tt.wantStop { + cb = func(index int, value interface{}) bool { + result = append(result, Entry{Index: index, Value: value}) + return len(result) >= 2 // Stop after 2 elements for early stop tests + } + } else { + cb = func(index int, value interface{}) bool { + result = append(result, Entry{Index: index, Value: value}) + return false + } + } + + stopped := list.IteratorByOffset(tt.offset, tt.count, cb) + + uassert.Equal(t, len(tt.expected), len(result), "comparing length") + for i := range result { + uassert.Equal(t, tt.expected[i].Index, result[i].Index, "comparing index") + uassert.Equal(t, typeutil.ToString(tt.expected[i].Value), typeutil.ToString(result[i].Value), "comparing value") + } + uassert.Equal(t, tt.wantStop, stopped, "comparing stopped") + }) + } +} + +func TestMustDelete(t *testing.T) { + tests := []struct { + name string + setup func() *List + indices []int + shouldPanic bool + panicMsg string + }{ + { + name: "successful delete", + setup: func() *List { + l := New() + l.Append(1, 2, 3) + return l + }, + indices: []int{1}, + shouldPanic: false, + }, + { + name: "out of bounds", + setup: func() *List { + l := New() + l.Append(1) + return l + }, + indices: []int{1}, + shouldPanic: true, + panicMsg: ErrOutOfBounds.Error(), + }, + { + name: "already deleted", + setup: func() *List { + l := New() + l.Append(1) + l.Delete(0) + return l + }, + indices: []int{0}, + shouldPanic: true, + panicMsg: ErrDeleted.Error(), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + l := tt.setup() + if tt.shouldPanic { + defer func() { + r := recover() + if r == nil { + t.Error("Expected panic but got none") + } + err, ok := r.(error) + if !ok { + t.Errorf("Expected error but got %v", r) + } + uassert.Equal(t, tt.panicMsg, err.Error()) + }() + } + l.MustDelete(tt.indices...) + if tt.shouldPanic { + t.Error("Expected panic") + } + }) + } +} + +func TestMustGet(t *testing.T) { + tests := []struct { + name string + setup func() *List + index int + expected interface{} + shouldPanic bool + panicMsg string + }{ + { + name: "successful get", + setup: func() *List { + l := New() + l.Append(42) + return l + }, + index: 0, + expected: 42, + shouldPanic: false, + }, + { + name: "out of bounds negative", + setup: func() *List { + l := New() + l.Append(1) + return l + }, + index: -1, + shouldPanic: true, + panicMsg: ErrOutOfBounds.Error(), + }, + { + name: "out of bounds positive", + setup: func() *List { + l := New() + l.Append(1) + return l + }, + index: 1, + shouldPanic: true, + panicMsg: ErrOutOfBounds.Error(), + }, + { + name: "deleted element", + setup: func() *List { + l := New() + l.Append(1) + l.Delete(0) + return l + }, + index: 0, + shouldPanic: true, + panicMsg: ErrDeleted.Error(), + }, + { + name: "nil list", + setup: func() *List { + return nil + }, + index: 0, + shouldPanic: true, + panicMsg: ErrOutOfBounds.Error(), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + l := tt.setup() + if tt.shouldPanic { + defer func() { + r := recover() + if r == nil { + t.Error("Expected panic but got none") + } + err, ok := r.(error) + if !ok { + t.Errorf("Expected error but got %v", r) + } + uassert.Equal(t, tt.panicMsg, err.Error()) + }() + } + result := l.MustGet(tt.index) + if tt.shouldPanic { + t.Error("Expected panic") + } + uassert.Equal(t, typeutil.ToString(tt.expected), typeutil.ToString(result)) + }) + } +} + +func TestGetRange(t *testing.T) { + tests := []struct { + name string + values []interface{} + start int + end int + expected []Entry + }{ + { + name: "empty list", + values: []interface{}{}, + start: 0, + end: 10, + expected: []Entry{}, + }, + { + name: "single element", + values: []interface{}{42}, + start: 0, + end: 0, + expected: []Entry{ + {Index: 0, Value: 42}, + }, + }, + { + name: "multiple elements forward", + values: []interface{}{1, 2, 3, 4, 5}, + start: 1, + end: 3, + expected: []Entry{ + {Index: 1, Value: 2}, + {Index: 2, Value: 3}, + {Index: 3, Value: 4}, + }, + }, + { + name: "multiple elements reverse", + values: []interface{}{1, 2, 3, 4, 5}, + start: 3, + end: 1, + expected: []Entry{ + {Index: 3, Value: 4}, + {Index: 2, Value: 3}, + {Index: 1, Value: 2}, + }, + }, + { + name: "with deleted elements", + values: []interface{}{1, nil, 3, nil, 5}, + start: 0, + end: 4, + expected: []Entry{ + {Index: 0, Value: 1}, + {Index: 2, Value: 3}, + {Index: 4, Value: 5}, + }, + }, + { + name: "nil list", + values: nil, + start: 0, + end: 5, + expected: []Entry{}, + }, + { + name: "negative indices", + values: []interface{}{1, 2, 3}, + start: -1, + end: -2, + expected: []Entry{}, + }, + { + name: "indices beyond size", + values: []interface{}{1, 2, 3}, + start: 1, + end: 5, + expected: []Entry{ + {Index: 1, Value: 2}, + {Index: 2, Value: 3}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + list := New() + list.Append(tt.values...) + + result := list.GetRange(tt.start, tt.end) + + uassert.Equal(t, len(tt.expected), len(result), "comparing length") + for i := range result { + uassert.Equal(t, tt.expected[i].Index, result[i].Index, "comparing index") + uassert.Equal(t, typeutil.ToString(tt.expected[i].Value), typeutil.ToString(result[i].Value), "comparing value") + } + }) + } +} + +func TestGetByOffset(t *testing.T) { + tests := []struct { + name string + values []interface{} + offset int + count int + expected []Entry + }{ + { + name: "empty list", + values: []interface{}{}, + offset: 0, + count: 5, + expected: []Entry{}, + }, + { + name: "positive count forward", + values: []interface{}{1, 2, 3, 4, 5}, + offset: 1, + count: 2, + expected: []Entry{ + {Index: 1, Value: 2}, + {Index: 2, Value: 3}, + }, + }, + { + name: "negative count backward", + values: []interface{}{1, 2, 3, 4, 5}, + offset: 3, + count: -2, + expected: []Entry{ + {Index: 3, Value: 4}, + {Index: 2, Value: 3}, + }, + }, + { + name: "count exceeds available elements", + values: []interface{}{1, 2, 3}, + offset: 1, + count: 5, + expected: []Entry{ + {Index: 1, Value: 2}, + {Index: 2, Value: 3}, + }, + }, + { + name: "zero count", + values: []interface{}{1, 2, 3}, + offset: 0, + count: 0, + expected: []Entry{}, + }, + { + name: "with deleted elements", + values: []interface{}{1, nil, 3, nil, 5}, + offset: 0, + count: 3, + expected: []Entry{ + {Index: 0, Value: 1}, + {Index: 2, Value: 3}, + {Index: 4, Value: 5}, + }, + }, + { + name: "negative offset", + values: []interface{}{1, 2, 3}, + offset: -1, + count: 2, + expected: []Entry{ + {Index: 0, Value: 1}, + {Index: 1, Value: 2}, + }, + }, + { + name: "offset beyond size", + values: []interface{}{1, 2, 3}, + offset: 5, + count: -2, + expected: []Entry{ + {Index: 2, Value: 3}, + {Index: 1, Value: 2}, + }, + }, + { + name: "nil list", + values: nil, + offset: 0, + count: 5, + expected: []Entry{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + list := New() + list.Append(tt.values...) + + result := list.GetByOffset(tt.offset, tt.count) + + uassert.Equal(t, len(tt.expected), len(result), "comparing length") + for i := range result { + uassert.Equal(t, tt.expected[i].Index, result[i].Index, "comparing index") + uassert.Equal(t, typeutil.ToString(tt.expected[i].Value), typeutil.ToString(result[i].Value), "comparing value") + } + }) + } +} + +func TestMustSet(t *testing.T) { + tests := []struct { + name string + setup func() *List + index int + value interface{} + shouldPanic bool + panicMsg string + }{ + { + name: "successful set", + setup: func() *List { + l := New() + l.Append(42) + return l + }, + index: 0, + value: 99, + shouldPanic: false, + }, + { + name: "restore deleted element", + setup: func() *List { + l := New() + l.Append(42) + l.Delete(0) + return l + }, + index: 0, + value: 99, + shouldPanic: false, + }, + { + name: "out of bounds negative", + setup: func() *List { + l := New() + l.Append(1) + return l + }, + index: -1, + value: 99, + shouldPanic: true, + panicMsg: ErrOutOfBounds.Error(), + }, + { + name: "out of bounds positive", + setup: func() *List { + l := New() + l.Append(1) + return l + }, + index: 1, + value: 99, + shouldPanic: true, + panicMsg: ErrOutOfBounds.Error(), + }, + { + name: "nil list", + setup: func() *List { + return nil + }, + index: 0, + value: 99, + shouldPanic: true, + panicMsg: ErrOutOfBounds.Error(), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + l := tt.setup() + if tt.shouldPanic { + defer func() { + r := recover() + if r == nil { + t.Error("Expected panic but got none") + } + err, ok := r.(error) + if !ok { + t.Errorf("Expected error but got %v", r) + } + uassert.Equal(t, tt.panicMsg, err.Error()) + }() + } + l.MustSet(tt.index, tt.value) + if tt.shouldPanic { + t.Error("Expected panic") + } + // Verify the value was set correctly for non-panic cases + if !tt.shouldPanic { + result := l.Get(tt.index) + uassert.Equal(t, typeutil.ToString(tt.value), typeutil.ToString(result)) + } + }) + } +} + +func TestSet(t *testing.T) { + tests := []struct { + name string + setup func() *List + index int + value interface{} + expectedErr error + verify func(t *testing.T, l *List) + }{ + { + name: "set value in empty list", + setup: func() *List { + return New() + }, + index: 0, + value: 42, + expectedErr: ErrOutOfBounds, + verify: func(t *testing.T, l *List) { + uassert.Equal(t, 0, l.Size()) + }, + }, + { + name: "set value at valid index", + setup: func() *List { + l := New() + l.Append(1) + return l + }, + index: 0, + value: 42, + verify: func(t *testing.T, l *List) { + uassert.Equal(t, 42, l.Get(0)) + uassert.Equal(t, 1, l.Size()) + uassert.Equal(t, 1, l.TotalSize()) + }, + }, + { + name: "set value at negative index", + setup: func() *List { + l := New() + l.Append(1) + return l + }, + index: -1, + value: 42, + expectedErr: ErrOutOfBounds, + verify: func(t *testing.T, l *List) { + uassert.Equal(t, 1, l.Get(0)) + }, + }, + { + name: "set value beyond size", + setup: func() *List { + l := New() + l.Append(1) + return l + }, + index: 1, + value: 42, + expectedErr: ErrOutOfBounds, + verify: func(t *testing.T, l *List) { + uassert.Equal(t, 1, l.Get(0)) + uassert.Equal(t, 1, l.Size()) + }, + }, + { + name: "set nil value", + setup: func() *List { + l := New() + l.Append(1) + return l + }, + index: 0, + value: nil, + verify: func(t *testing.T, l *List) { + uassert.Equal(t, nil, l.Get(0)) + uassert.Equal(t, 0, l.Size()) + }, + }, + { + name: "set value at deleted index", + setup: func() *List { + l := New() + l.Append(1, 2, 3) + l.Delete(1) + return l + }, + index: 1, + value: 42, + verify: func(t *testing.T, l *List) { + uassert.Equal(t, 42, l.Get(1)) + uassert.Equal(t, 3, l.Size()) + uassert.Equal(t, 3, l.TotalSize()) + }, + }, + { + name: "set value in nil list", + setup: func() *List { + return nil + }, + index: 0, + value: 42, + expectedErr: ErrOutOfBounds, + verify: func(t *testing.T, l *List) { + uassert.Equal(t, 0, l.Size()) + }, + }, + { + name: "set multiple values at same index", + setup: func() *List { + l := New() + l.Append(1) + return l + }, + index: 0, + value: 42, + verify: func(t *testing.T, l *List) { + uassert.Equal(t, 42, l.Get(0)) + err := l.Set(0, 99) + uassert.Equal(t, nil, err) + uassert.Equal(t, 99, l.Get(0)) + uassert.Equal(t, 1, l.Size()) + }, + }, + { + name: "set value at last index", + setup: func() *List { + l := New() + l.Append(1, 2, 3) + return l + }, + index: 2, + value: 42, + verify: func(t *testing.T, l *List) { + uassert.Equal(t, 42, l.Get(2)) + uassert.Equal(t, 3, l.Size()) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + l := tt.setup() + err := l.Set(tt.index, tt.value) + + if tt.expectedErr != nil { + uassert.Equal(t, tt.expectedErr.Error(), err.Error()) + } else { + uassert.Equal(t, nil, err) + } + + tt.verify(t, l) + }) + } +} diff --git a/examples/gno.land/p/moul/xmath/generate.go b/examples/gno.land/p/moul/xmath/generate.go new file mode 100644 index 00000000000..ad70adb06bd --- /dev/null +++ b/examples/gno.land/p/moul/xmath/generate.go @@ -0,0 +1,3 @@ +package xmath + +//go:generate go run generator.go diff --git a/examples/gno.land/p/moul/xmath/generator.go b/examples/gno.land/p/moul/xmath/generator.go new file mode 100644 index 00000000000..afe5a4341fa --- /dev/null +++ b/examples/gno.land/p/moul/xmath/generator.go @@ -0,0 +1,184 @@ +//go:build ignore + +package main + +import ( + "bytes" + "fmt" + "go/format" + "log" + "os" + "strings" + "text/template" +) + +type Type struct { + Name string + ZeroValue string + Signed bool + Float bool +} + +var types = []Type{ + {"Int8", "0", true, false}, + {"Int16", "0", true, false}, + {"Int32", "0", true, false}, + {"Int64", "0", true, false}, + {"Int", "0", true, false}, + {"Uint8", "0", false, false}, + {"Uint16", "0", false, false}, + {"Uint32", "0", false, false}, + {"Uint64", "0", false, false}, + {"Uint", "0", false, false}, + {"Float32", "0.0", true, true}, + {"Float64", "0.0", true, true}, +} + +const sourceTpl = `// Code generated by generator.go; DO NOT EDIT. +package xmath + +{{ range .Types }} +// {{.Name}} helpers +func Max{{.Name}}(a, b {{.Name | lower}}) {{.Name | lower}} { + if a > b { + return a + } + return b +} + +func Min{{.Name}}(a, b {{.Name | lower}}) {{.Name | lower}} { + if a < b { + return a + } + return b +} + +func Clamp{{.Name}}(value, min, max {{.Name | lower}}) {{.Name | lower}} { + if value < min { + return min + } + if value > max { + return max + } + return value +} +{{if .Signed}} +func Abs{{.Name}}(x {{.Name | lower}}) {{.Name | lower}} { + if x < 0 { + return -x + } + return x +} + +func Sign{{.Name}}(x {{.Name | lower}}) {{.Name | lower}} { + if x < 0 { + return -1 + } + if x > 0 { + return 1 + } + return 0 +} +{{end}} +{{end}} +` + +const testTpl = `package xmath + +import "testing" + +{{range .Types}} +func Test{{.Name}}Helpers(t *testing.T) { + // Test Max{{.Name}} + if Max{{.Name}}(1, 2) != 2 { + t.Error("Max{{.Name}}(1, 2) should be 2") + } + {{if .Signed}}if Max{{.Name}}(-1, -2) != -1 { + t.Error("Max{{.Name}}(-1, -2) should be -1") + }{{end}} + + // Test Min{{.Name}} + if Min{{.Name}}(1, 2) != 1 { + t.Error("Min{{.Name}}(1, 2) should be 1") + } + {{if .Signed}}if Min{{.Name}}(-1, -2) != -2 { + t.Error("Min{{.Name}}(-1, -2) should be -2") + }{{end}} + + // Test Clamp{{.Name}} + if Clamp{{.Name}}(5, 1, 3) != 3 { + t.Error("Clamp{{.Name}}(5, 1, 3) should be 3") + } + if Clamp{{.Name}}(0, 1, 3) != 1 { + t.Error("Clamp{{.Name}}(0, 1, 3) should be 1") + } + if Clamp{{.Name}}(2, 1, 3) != 2 { + t.Error("Clamp{{.Name}}(2, 1, 3) should be 2") + } + {{if .Signed}} + // Test Abs{{.Name}} + if Abs{{.Name}}(-5) != 5 { + t.Error("Abs{{.Name}}(-5) should be 5") + } + if Abs{{.Name}}(5) != 5 { + t.Error("Abs{{.Name}}(5) should be 5") + } + + // Test Sign{{.Name}} + if Sign{{.Name}}(-5) != -1 { + t.Error("Sign{{.Name}}(-5) should be -1") + } + if Sign{{.Name}}(5) != 1 { + t.Error("Sign{{.Name}}(5) should be 1") + } + if Sign{{.Name}}({{.ZeroValue}}) != 0 { + t.Error("Sign{{.Name}}({{.ZeroValue}}) should be 0") + } + {{end}} +} +{{end}} +` + +func main() { + funcMap := template.FuncMap{ + "lower": strings.ToLower, + } + + // Generate source file + sourceTmpl := template.Must(template.New("source").Funcs(funcMap).Parse(sourceTpl)) + var sourceOut bytes.Buffer + if err := sourceTmpl.Execute(&sourceOut, struct{ Types []Type }{types}); err != nil { + log.Fatal(err) + } + + // Format the generated code + formattedSource, err := format.Source(sourceOut.Bytes()) + if err != nil { + log.Fatal(err) + } + + // Write source file + if err := os.WriteFile("xmath.gen.gno", formattedSource, 0644); err != nil { + log.Fatal(err) + } + + // Generate test file + testTmpl := template.Must(template.New("test").Parse(testTpl)) + var testOut bytes.Buffer + if err := testTmpl.Execute(&testOut, struct{ Types []Type }{types}); err != nil { + log.Fatal(err) + } + + // Format the generated test code + formattedTest, err := format.Source(testOut.Bytes()) + if err != nil { + log.Fatal(err) + } + + // Write test file + if err := os.WriteFile("xmath.gen_test.gno", formattedTest, 0644); err != nil { + log.Fatal(err) + } + + fmt.Println("Generated xmath.gen.gno and xmath.gen_test.gno") +} diff --git a/examples/gno.land/p/moul/xmath/gno.mod b/examples/gno.land/p/moul/xmath/gno.mod new file mode 100644 index 00000000000..63b782c88f2 --- /dev/null +++ b/examples/gno.land/p/moul/xmath/gno.mod @@ -0,0 +1 @@ +module gno.land/p/moul/xmath diff --git a/examples/gno.land/p/moul/xmath/xmath.gen.gno b/examples/gno.land/p/moul/xmath/xmath.gen.gno new file mode 100644 index 00000000000..266c77e1e84 --- /dev/null +++ b/examples/gno.land/p/moul/xmath/xmath.gen.gno @@ -0,0 +1,421 @@ +// Code generated by generator.go; DO NOT EDIT. +package xmath + +// Int8 helpers +func MaxInt8(a, b int8) int8 { + if a > b { + return a + } + return b +} + +func MinInt8(a, b int8) int8 { + if a < b { + return a + } + return b +} + +func ClampInt8(value, min, max int8) int8 { + if value < min { + return min + } + if value > max { + return max + } + return value +} + +func AbsInt8(x int8) int8 { + if x < 0 { + return -x + } + return x +} + +func SignInt8(x int8) int8 { + if x < 0 { + return -1 + } + if x > 0 { + return 1 + } + return 0 +} + +// Int16 helpers +func MaxInt16(a, b int16) int16 { + if a > b { + return a + } + return b +} + +func MinInt16(a, b int16) int16 { + if a < b { + return a + } + return b +} + +func ClampInt16(value, min, max int16) int16 { + if value < min { + return min + } + if value > max { + return max + } + return value +} + +func AbsInt16(x int16) int16 { + if x < 0 { + return -x + } + return x +} + +func SignInt16(x int16) int16 { + if x < 0 { + return -1 + } + if x > 0 { + return 1 + } + return 0 +} + +// Int32 helpers +func MaxInt32(a, b int32) int32 { + if a > b { + return a + } + return b +} + +func MinInt32(a, b int32) int32 { + if a < b { + return a + } + return b +} + +func ClampInt32(value, min, max int32) int32 { + if value < min { + return min + } + if value > max { + return max + } + return value +} + +func AbsInt32(x int32) int32 { + if x < 0 { + return -x + } + return x +} + +func SignInt32(x int32) int32 { + if x < 0 { + return -1 + } + if x > 0 { + return 1 + } + return 0 +} + +// Int64 helpers +func MaxInt64(a, b int64) int64 { + if a > b { + return a + } + return b +} + +func MinInt64(a, b int64) int64 { + if a < b { + return a + } + return b +} + +func ClampInt64(value, min, max int64) int64 { + if value < min { + return min + } + if value > max { + return max + } + return value +} + +func AbsInt64(x int64) int64 { + if x < 0 { + return -x + } + return x +} + +func SignInt64(x int64) int64 { + if x < 0 { + return -1 + } + if x > 0 { + return 1 + } + return 0 +} + +// Int helpers +func MaxInt(a, b int) int { + if a > b { + return a + } + return b +} + +func MinInt(a, b int) int { + if a < b { + return a + } + return b +} + +func ClampInt(value, min, max int) int { + if value < min { + return min + } + if value > max { + return max + } + return value +} + +func AbsInt(x int) int { + if x < 0 { + return -x + } + return x +} + +func SignInt(x int) int { + if x < 0 { + return -1 + } + if x > 0 { + return 1 + } + return 0 +} + +// Uint8 helpers +func MaxUint8(a, b uint8) uint8 { + if a > b { + return a + } + return b +} + +func MinUint8(a, b uint8) uint8 { + if a < b { + return a + } + return b +} + +func ClampUint8(value, min, max uint8) uint8 { + if value < min { + return min + } + if value > max { + return max + } + return value +} + +// Uint16 helpers +func MaxUint16(a, b uint16) uint16 { + if a > b { + return a + } + return b +} + +func MinUint16(a, b uint16) uint16 { + if a < b { + return a + } + return b +} + +func ClampUint16(value, min, max uint16) uint16 { + if value < min { + return min + } + if value > max { + return max + } + return value +} + +// Uint32 helpers +func MaxUint32(a, b uint32) uint32 { + if a > b { + return a + } + return b +} + +func MinUint32(a, b uint32) uint32 { + if a < b { + return a + } + return b +} + +func ClampUint32(value, min, max uint32) uint32 { + if value < min { + return min + } + if value > max { + return max + } + return value +} + +// Uint64 helpers +func MaxUint64(a, b uint64) uint64 { + if a > b { + return a + } + return b +} + +func MinUint64(a, b uint64) uint64 { + if a < b { + return a + } + return b +} + +func ClampUint64(value, min, max uint64) uint64 { + if value < min { + return min + } + if value > max { + return max + } + return value +} + +// Uint helpers +func MaxUint(a, b uint) uint { + if a > b { + return a + } + return b +} + +func MinUint(a, b uint) uint { + if a < b { + return a + } + return b +} + +func ClampUint(value, min, max uint) uint { + if value < min { + return min + } + if value > max { + return max + } + return value +} + +// Float32 helpers +func MaxFloat32(a, b float32) float32 { + if a > b { + return a + } + return b +} + +func MinFloat32(a, b float32) float32 { + if a < b { + return a + } + return b +} + +func ClampFloat32(value, min, max float32) float32 { + if value < min { + return min + } + if value > max { + return max + } + return value +} + +func AbsFloat32(x float32) float32 { + if x < 0 { + return -x + } + return x +} + +func SignFloat32(x float32) float32 { + if x < 0 { + return -1 + } + if x > 0 { + return 1 + } + return 0 +} + +// Float64 helpers +func MaxFloat64(a, b float64) float64 { + if a > b { + return a + } + return b +} + +func MinFloat64(a, b float64) float64 { + if a < b { + return a + } + return b +} + +func ClampFloat64(value, min, max float64) float64 { + if value < min { + return min + } + if value > max { + return max + } + return value +} + +func AbsFloat64(x float64) float64 { + if x < 0 { + return -x + } + return x +} + +func SignFloat64(x float64) float64 { + if x < 0 { + return -1 + } + if x > 0 { + return 1 + } + return 0 +} diff --git a/examples/gno.land/p/moul/xmath/xmath.gen_test.gno b/examples/gno.land/p/moul/xmath/xmath.gen_test.gno new file mode 100644 index 00000000000..16c80fc983d --- /dev/null +++ b/examples/gno.land/p/moul/xmath/xmath.gen_test.gno @@ -0,0 +1,466 @@ +package xmath + +import "testing" + +func TestInt8Helpers(t *testing.T) { + // Test MaxInt8 + if MaxInt8(1, 2) != 2 { + t.Error("MaxInt8(1, 2) should be 2") + } + if MaxInt8(-1, -2) != -1 { + t.Error("MaxInt8(-1, -2) should be -1") + } + + // Test MinInt8 + if MinInt8(1, 2) != 1 { + t.Error("MinInt8(1, 2) should be 1") + } + if MinInt8(-1, -2) != -2 { + t.Error("MinInt8(-1, -2) should be -2") + } + + // Test ClampInt8 + if ClampInt8(5, 1, 3) != 3 { + t.Error("ClampInt8(5, 1, 3) should be 3") + } + if ClampInt8(0, 1, 3) != 1 { + t.Error("ClampInt8(0, 1, 3) should be 1") + } + if ClampInt8(2, 1, 3) != 2 { + t.Error("ClampInt8(2, 1, 3) should be 2") + } + + // Test AbsInt8 + if AbsInt8(-5) != 5 { + t.Error("AbsInt8(-5) should be 5") + } + if AbsInt8(5) != 5 { + t.Error("AbsInt8(5) should be 5") + } + + // Test SignInt8 + if SignInt8(-5) != -1 { + t.Error("SignInt8(-5) should be -1") + } + if SignInt8(5) != 1 { + t.Error("SignInt8(5) should be 1") + } + if SignInt8(0) != 0 { + t.Error("SignInt8(0) should be 0") + } + +} + +func TestInt16Helpers(t *testing.T) { + // Test MaxInt16 + if MaxInt16(1, 2) != 2 { + t.Error("MaxInt16(1, 2) should be 2") + } + if MaxInt16(-1, -2) != -1 { + t.Error("MaxInt16(-1, -2) should be -1") + } + + // Test MinInt16 + if MinInt16(1, 2) != 1 { + t.Error("MinInt16(1, 2) should be 1") + } + if MinInt16(-1, -2) != -2 { + t.Error("MinInt16(-1, -2) should be -2") + } + + // Test ClampInt16 + if ClampInt16(5, 1, 3) != 3 { + t.Error("ClampInt16(5, 1, 3) should be 3") + } + if ClampInt16(0, 1, 3) != 1 { + t.Error("ClampInt16(0, 1, 3) should be 1") + } + if ClampInt16(2, 1, 3) != 2 { + t.Error("ClampInt16(2, 1, 3) should be 2") + } + + // Test AbsInt16 + if AbsInt16(-5) != 5 { + t.Error("AbsInt16(-5) should be 5") + } + if AbsInt16(5) != 5 { + t.Error("AbsInt16(5) should be 5") + } + + // Test SignInt16 + if SignInt16(-5) != -1 { + t.Error("SignInt16(-5) should be -1") + } + if SignInt16(5) != 1 { + t.Error("SignInt16(5) should be 1") + } + if SignInt16(0) != 0 { + t.Error("SignInt16(0) should be 0") + } + +} + +func TestInt32Helpers(t *testing.T) { + // Test MaxInt32 + if MaxInt32(1, 2) != 2 { + t.Error("MaxInt32(1, 2) should be 2") + } + if MaxInt32(-1, -2) != -1 { + t.Error("MaxInt32(-1, -2) should be -1") + } + + // Test MinInt32 + if MinInt32(1, 2) != 1 { + t.Error("MinInt32(1, 2) should be 1") + } + if MinInt32(-1, -2) != -2 { + t.Error("MinInt32(-1, -2) should be -2") + } + + // Test ClampInt32 + if ClampInt32(5, 1, 3) != 3 { + t.Error("ClampInt32(5, 1, 3) should be 3") + } + if ClampInt32(0, 1, 3) != 1 { + t.Error("ClampInt32(0, 1, 3) should be 1") + } + if ClampInt32(2, 1, 3) != 2 { + t.Error("ClampInt32(2, 1, 3) should be 2") + } + + // Test AbsInt32 + if AbsInt32(-5) != 5 { + t.Error("AbsInt32(-5) should be 5") + } + if AbsInt32(5) != 5 { + t.Error("AbsInt32(5) should be 5") + } + + // Test SignInt32 + if SignInt32(-5) != -1 { + t.Error("SignInt32(-5) should be -1") + } + if SignInt32(5) != 1 { + t.Error("SignInt32(5) should be 1") + } + if SignInt32(0) != 0 { + t.Error("SignInt32(0) should be 0") + } + +} + +func TestInt64Helpers(t *testing.T) { + // Test MaxInt64 + if MaxInt64(1, 2) != 2 { + t.Error("MaxInt64(1, 2) should be 2") + } + if MaxInt64(-1, -2) != -1 { + t.Error("MaxInt64(-1, -2) should be -1") + } + + // Test MinInt64 + if MinInt64(1, 2) != 1 { + t.Error("MinInt64(1, 2) should be 1") + } + if MinInt64(-1, -2) != -2 { + t.Error("MinInt64(-1, -2) should be -2") + } + + // Test ClampInt64 + if ClampInt64(5, 1, 3) != 3 { + t.Error("ClampInt64(5, 1, 3) should be 3") + } + if ClampInt64(0, 1, 3) != 1 { + t.Error("ClampInt64(0, 1, 3) should be 1") + } + if ClampInt64(2, 1, 3) != 2 { + t.Error("ClampInt64(2, 1, 3) should be 2") + } + + // Test AbsInt64 + if AbsInt64(-5) != 5 { + t.Error("AbsInt64(-5) should be 5") + } + if AbsInt64(5) != 5 { + t.Error("AbsInt64(5) should be 5") + } + + // Test SignInt64 + if SignInt64(-5) != -1 { + t.Error("SignInt64(-5) should be -1") + } + if SignInt64(5) != 1 { + t.Error("SignInt64(5) should be 1") + } + if SignInt64(0) != 0 { + t.Error("SignInt64(0) should be 0") + } + +} + +func TestIntHelpers(t *testing.T) { + // Test MaxInt + if MaxInt(1, 2) != 2 { + t.Error("MaxInt(1, 2) should be 2") + } + if MaxInt(-1, -2) != -1 { + t.Error("MaxInt(-1, -2) should be -1") + } + + // Test MinInt + if MinInt(1, 2) != 1 { + t.Error("MinInt(1, 2) should be 1") + } + if MinInt(-1, -2) != -2 { + t.Error("MinInt(-1, -2) should be -2") + } + + // Test ClampInt + if ClampInt(5, 1, 3) != 3 { + t.Error("ClampInt(5, 1, 3) should be 3") + } + if ClampInt(0, 1, 3) != 1 { + t.Error("ClampInt(0, 1, 3) should be 1") + } + if ClampInt(2, 1, 3) != 2 { + t.Error("ClampInt(2, 1, 3) should be 2") + } + + // Test AbsInt + if AbsInt(-5) != 5 { + t.Error("AbsInt(-5) should be 5") + } + if AbsInt(5) != 5 { + t.Error("AbsInt(5) should be 5") + } + + // Test SignInt + if SignInt(-5) != -1 { + t.Error("SignInt(-5) should be -1") + } + if SignInt(5) != 1 { + t.Error("SignInt(5) should be 1") + } + if SignInt(0) != 0 { + t.Error("SignInt(0) should be 0") + } + +} + +func TestUint8Helpers(t *testing.T) { + // Test MaxUint8 + if MaxUint8(1, 2) != 2 { + t.Error("MaxUint8(1, 2) should be 2") + } + + // Test MinUint8 + if MinUint8(1, 2) != 1 { + t.Error("MinUint8(1, 2) should be 1") + } + + // Test ClampUint8 + if ClampUint8(5, 1, 3) != 3 { + t.Error("ClampUint8(5, 1, 3) should be 3") + } + if ClampUint8(0, 1, 3) != 1 { + t.Error("ClampUint8(0, 1, 3) should be 1") + } + if ClampUint8(2, 1, 3) != 2 { + t.Error("ClampUint8(2, 1, 3) should be 2") + } + +} + +func TestUint16Helpers(t *testing.T) { + // Test MaxUint16 + if MaxUint16(1, 2) != 2 { + t.Error("MaxUint16(1, 2) should be 2") + } + + // Test MinUint16 + if MinUint16(1, 2) != 1 { + t.Error("MinUint16(1, 2) should be 1") + } + + // Test ClampUint16 + if ClampUint16(5, 1, 3) != 3 { + t.Error("ClampUint16(5, 1, 3) should be 3") + } + if ClampUint16(0, 1, 3) != 1 { + t.Error("ClampUint16(0, 1, 3) should be 1") + } + if ClampUint16(2, 1, 3) != 2 { + t.Error("ClampUint16(2, 1, 3) should be 2") + } + +} + +func TestUint32Helpers(t *testing.T) { + // Test MaxUint32 + if MaxUint32(1, 2) != 2 { + t.Error("MaxUint32(1, 2) should be 2") + } + + // Test MinUint32 + if MinUint32(1, 2) != 1 { + t.Error("MinUint32(1, 2) should be 1") + } + + // Test ClampUint32 + if ClampUint32(5, 1, 3) != 3 { + t.Error("ClampUint32(5, 1, 3) should be 3") + } + if ClampUint32(0, 1, 3) != 1 { + t.Error("ClampUint32(0, 1, 3) should be 1") + } + if ClampUint32(2, 1, 3) != 2 { + t.Error("ClampUint32(2, 1, 3) should be 2") + } + +} + +func TestUint64Helpers(t *testing.T) { + // Test MaxUint64 + if MaxUint64(1, 2) != 2 { + t.Error("MaxUint64(1, 2) should be 2") + } + + // Test MinUint64 + if MinUint64(1, 2) != 1 { + t.Error("MinUint64(1, 2) should be 1") + } + + // Test ClampUint64 + if ClampUint64(5, 1, 3) != 3 { + t.Error("ClampUint64(5, 1, 3) should be 3") + } + if ClampUint64(0, 1, 3) != 1 { + t.Error("ClampUint64(0, 1, 3) should be 1") + } + if ClampUint64(2, 1, 3) != 2 { + t.Error("ClampUint64(2, 1, 3) should be 2") + } + +} + +func TestUintHelpers(t *testing.T) { + // Test MaxUint + if MaxUint(1, 2) != 2 { + t.Error("MaxUint(1, 2) should be 2") + } + + // Test MinUint + if MinUint(1, 2) != 1 { + t.Error("MinUint(1, 2) should be 1") + } + + // Test ClampUint + if ClampUint(5, 1, 3) != 3 { + t.Error("ClampUint(5, 1, 3) should be 3") + } + if ClampUint(0, 1, 3) != 1 { + t.Error("ClampUint(0, 1, 3) should be 1") + } + if ClampUint(2, 1, 3) != 2 { + t.Error("ClampUint(2, 1, 3) should be 2") + } + +} + +func TestFloat32Helpers(t *testing.T) { + // Test MaxFloat32 + if MaxFloat32(1, 2) != 2 { + t.Error("MaxFloat32(1, 2) should be 2") + } + if MaxFloat32(-1, -2) != -1 { + t.Error("MaxFloat32(-1, -2) should be -1") + } + + // Test MinFloat32 + if MinFloat32(1, 2) != 1 { + t.Error("MinFloat32(1, 2) should be 1") + } + if MinFloat32(-1, -2) != -2 { + t.Error("MinFloat32(-1, -2) should be -2") + } + + // Test ClampFloat32 + if ClampFloat32(5, 1, 3) != 3 { + t.Error("ClampFloat32(5, 1, 3) should be 3") + } + if ClampFloat32(0, 1, 3) != 1 { + t.Error("ClampFloat32(0, 1, 3) should be 1") + } + if ClampFloat32(2, 1, 3) != 2 { + t.Error("ClampFloat32(2, 1, 3) should be 2") + } + + // Test AbsFloat32 + if AbsFloat32(-5) != 5 { + t.Error("AbsFloat32(-5) should be 5") + } + if AbsFloat32(5) != 5 { + t.Error("AbsFloat32(5) should be 5") + } + + // Test SignFloat32 + if SignFloat32(-5) != -1 { + t.Error("SignFloat32(-5) should be -1") + } + if SignFloat32(5) != 1 { + t.Error("SignFloat32(5) should be 1") + } + if SignFloat32(0.0) != 0 { + t.Error("SignFloat32(0.0) should be 0") + } + +} + +func TestFloat64Helpers(t *testing.T) { + // Test MaxFloat64 + if MaxFloat64(1, 2) != 2 { + t.Error("MaxFloat64(1, 2) should be 2") + } + if MaxFloat64(-1, -2) != -1 { + t.Error("MaxFloat64(-1, -2) should be -1") + } + + // Test MinFloat64 + if MinFloat64(1, 2) != 1 { + t.Error("MinFloat64(1, 2) should be 1") + } + if MinFloat64(-1, -2) != -2 { + t.Error("MinFloat64(-1, -2) should be -2") + } + + // Test ClampFloat64 + if ClampFloat64(5, 1, 3) != 3 { + t.Error("ClampFloat64(5, 1, 3) should be 3") + } + if ClampFloat64(0, 1, 3) != 1 { + t.Error("ClampFloat64(0, 1, 3) should be 1") + } + if ClampFloat64(2, 1, 3) != 2 { + t.Error("ClampFloat64(2, 1, 3) should be 2") + } + + // Test AbsFloat64 + if AbsFloat64(-5) != 5 { + t.Error("AbsFloat64(-5) should be 5") + } + if AbsFloat64(5) != 5 { + t.Error("AbsFloat64(5) should be 5") + } + + // Test SignFloat64 + if SignFloat64(-5) != -1 { + t.Error("SignFloat64(-5) should be -1") + } + if SignFloat64(5) != 1 { + t.Error("SignFloat64(5) should be 1") + } + if SignFloat64(0.0) != 0 { + t.Error("SignFloat64(0.0) should be 0") + } + +} diff --git a/examples/gno.land/p/n2p5/loci/gno.mod b/examples/gno.land/p/n2p5/loci/gno.mod new file mode 100644 index 00000000000..ec30d72d752 --- /dev/null +++ b/examples/gno.land/p/n2p5/loci/gno.mod @@ -0,0 +1 @@ +module gno.land/p/n2p5/loci diff --git a/examples/gno.land/p/n2p5/loci/loci.gno b/examples/gno.land/p/n2p5/loci/loci.gno new file mode 100644 index 00000000000..7bd5c29c3af --- /dev/null +++ b/examples/gno.land/p/n2p5/loci/loci.gno @@ -0,0 +1,44 @@ +// loci is a single purpose datastore keyed by the caller's address. It has two +// functions: Set and Get. loci is plural for locus, which is a central or core +// place where something is found or from which it originates. In this case, +// it's a simple key-value store where an address (the key) can store exactly +// one value (in the form of a byte slice). Only the caller can set the value +// for their address, but anyone can retrieve the value for any address. +package loci + +import ( + "std" + + "gno.land/p/demo/avl" +) + +// LociStore is a simple key-value store that uses +// an AVL tree to store the data. +type LociStore struct { + internal *avl.Tree +} + +// New creates a reference to a new LociStore. +func New() *LociStore { + return &LociStore{ + internal: avl.NewTree(), + } +} + +// Set stores a byte slice in the AVL tree using the `std.PrevRealm().Addr()` +// string as the key. +func (s *LociStore) Set(value []byte) { + key := string(std.PrevRealm().Addr()) + s.internal.Set(key, value) +} + +// Get retrieves a byte slice from the AVL tree using the provided address. +// The return values are the byte slice value and a boolean indicating +// whether the value exists. +func (s *LociStore) Get(addr std.Address) []byte { + value, exists := s.internal.Get(string(addr)) + if !exists { + return nil + } + return value.([]byte) +} diff --git a/examples/gno.land/p/n2p5/loci/loci_test.gno b/examples/gno.land/p/n2p5/loci/loci_test.gno new file mode 100644 index 00000000000..bb216a8539e --- /dev/null +++ b/examples/gno.land/p/n2p5/loci/loci_test.gno @@ -0,0 +1,84 @@ +package loci + +import ( + "std" + "testing" + + "gno.land/p/demo/testutils" +) + +func TestLociStore(t *testing.T) { + t.Parallel() + + u1 := testutils.TestAddress("u1") + u2 := testutils.TestAddress("u1") + + t.Run("TestSet", func(t *testing.T) { + t.Parallel() + store := New() + u1 := testutils.TestAddress("u1") + + m1 := []byte("hello") + m2 := []byte("world") + std.TestSetOrigCaller(u1) + + // Ensure that the value is nil before setting it. + r1 := store.Get(u1) + if r1 != nil { + t.Errorf("expected value to be nil, got '%s'", r1) + } + store.Set(m1) + // Ensure that the value is correct after setting it. + r2 := store.Get(u1) + if string(r2) != "hello" { + t.Errorf("expected value to be 'hello', got '%s'", r2) + } + store.Set(m2) + // Ensure that the value is correct after overwriting it. + r3 := store.Get(u1) + if string(r3) != "world" { + t.Errorf("expected value to be 'world', got '%s'", r3) + } + }) + t.Run("TestGet", func(t *testing.T) { + t.Parallel() + store := New() + u1 := testutils.TestAddress("u1") + u2 := testutils.TestAddress("u2") + u3 := testutils.TestAddress("u3") + u4 := testutils.TestAddress("u4") + + m1 := []byte("hello") + m2 := []byte("world") + m3 := []byte("goodbye") + + std.TestSetOrigCaller(u1) + store.Set(m1) + std.TestSetOrigCaller(u2) + store.Set(m2) + std.TestSetOrigCaller(u3) + store.Set(m3) + + // Ensure that the value is correct after setting it. + r0 := store.Get(u4) + if r0 != nil { + t.Errorf("expected value to be nil, got '%s'", r0) + } + // Ensure that the value is correct after setting it. + r1 := store.Get(u1) + if string(r1) != "hello" { + t.Errorf("expected value to be 'hello', got '%s'", r1) + } + // Ensure that the value is correct after setting it. + r2 := store.Get(u2) + if string(r2) != "world" { + t.Errorf("expected value to be 'world', got '%s'", r2) + } + // Ensure that the value is correct after setting it. + r3 := store.Get(u3) + if string(r3) != "goodbye" { + t.Errorf("expected value to be 'goodbye', got '%s'", r3) + } + }) + +} diff --git a/examples/gno.land/p/n2p5/mgroup/mgroup.gno b/examples/gno.land/p/n2p5/mgroup/mgroup.gno index 0c029401ff7..566d625a003 100644 --- a/examples/gno.land/p/n2p5/mgroup/mgroup.gno +++ b/examples/gno.land/p/n2p5/mgroup/mgroup.gno @@ -44,8 +44,8 @@ func New(ownerAddress std.Address) *ManagedGroup { // AddBackupOwner adds a backup owner to the group by std.Address. // If the caller is not the owner, an error is returned. func (g *ManagedGroup) AddBackupOwner(addr std.Address) error { - if err := g.owner.CallerIsOwner(); err != nil { - return err + if !g.owner.CallerIsOwner() { + return ownable.ErrUnauthorized } if !addr.IsValid() { return ErrInvalidAddress @@ -57,8 +57,8 @@ func (g *ManagedGroup) AddBackupOwner(addr std.Address) error { // RemoveBackupOwner removes a backup owner from the group by std.Address. // The owner cannot be removed. If the caller is not the owner, an error is returned. func (g *ManagedGroup) RemoveBackupOwner(addr std.Address) error { - if err := g.owner.CallerIsOwner(); err != nil { - return err + if !g.owner.CallerIsOwner() { + return ownable.ErrUnauthorized } if !addr.IsValid() { return ErrInvalidAddress @@ -90,8 +90,8 @@ func (g *ManagedGroup) ClaimOwnership() error { // AddMember adds a member to the group by std.Address. // If the caller is not the owner, an error is returned. func (g *ManagedGroup) AddMember(addr std.Address) error { - if err := g.owner.CallerIsOwner(); err != nil { - return err + if !g.owner.CallerIsOwner() { + return ownable.ErrUnauthorized } if !addr.IsValid() { return ErrInvalidAddress @@ -104,8 +104,8 @@ func (g *ManagedGroup) AddMember(addr std.Address) error { // The owner cannot be removed. If the caller is not the owner, // an error is returned. func (g *ManagedGroup) RemoveMember(addr std.Address) error { - if err := g.owner.CallerIsOwner(); err != nil { - return err + if !g.owner.CallerIsOwner() { + return ownable.ErrUnauthorized } if !addr.IsValid() { return ErrInvalidAddress diff --git a/examples/gno.land/p/n2p5/mgroup/mgroup_test.gno b/examples/gno.land/p/n2p5/mgroup/mgroup_test.gno index 7ef0619188f..cd02db98683 100644 --- a/examples/gno.land/p/n2p5/mgroup/mgroup_test.gno +++ b/examples/gno.land/p/n2p5/mgroup/mgroup_test.gno @@ -297,13 +297,13 @@ func TestManagedGroup(t *testing.T) { if len(owners) != 3 { t.Errorf("expected 2, got %v", len(owners)) } - if owners[0] != u2.String() { + if owners[0] != u1.String() { t.Errorf("expected %v, got %v", u2, owners[0]) } if owners[1] != u3.String() { t.Errorf("expected %v, got %v", u3, owners[1]) } - if owners[2] != u1.String() { + if owners[2] != u2.String() { t.Errorf("expected %v, got %v", u3, owners[1]) } }) @@ -317,13 +317,13 @@ func TestManagedGroup(t *testing.T) { if len(members) != 3 { t.Errorf("expected 2, got %v", len(members)) } - if members[0] != u2.String() { + if members[0] != u1.String() { t.Errorf("expected %v, got %v", u2, members[0]) } if members[1] != u3.String() { t.Errorf("expected %v, got %v", u3, members[1]) } - if members[2] != u1.String() { + if members[2] != u2.String() { t.Errorf("expected %v, got %v", u3, members[1]) } }) diff --git a/examples/gno.land/r/demo/bar20/bar20.gno b/examples/gno.land/r/demo/bar20/bar20.gno index 25636fcda78..52f1baa7408 100644 --- a/examples/gno.land/r/demo/bar20/bar20.gno +++ b/examples/gno.land/r/demo/bar20/bar20.gno @@ -18,8 +18,7 @@ var ( ) func init() { - getter := func() *grc20.Token { return Token } - grc20reg.Register(getter, "") + grc20reg.Register(Token.Getter(), "") } func Faucet() string { diff --git a/examples/gno.land/r/demo/boards/z_4_filetest.gno b/examples/gno.land/r/demo/boards/z_4_filetest.gno index c6cf6397b3a..b781e94e4db 100644 --- a/examples/gno.land/r/demo/boards/z_4_filetest.gno +++ b/examples/gno.land/r/demo/boards/z_4_filetest.gno @@ -885,6 +885,25 @@ func main() { // "RefCount": "1" // } // } +// u[f6dbf411da22e67d74cd7ddba6a76cd7e14a4822:84]={ +// "ObjectInfo": { +// "ID": "f6dbf411da22e67d74cd7ddba6a76cd7e14a4822:84", +// "IsEscaped": true, +// "ModTime": "127", +// "RefCount": "6" +// }, +// "Value": { +// "T": { +// "@type": "/gno.RefType", +// "ID": "gno.land/r/demo/boards.Board" +// }, +// "V": { +// "@type": "/gno.RefValue", +// "Hash": "a88a9b837af217656ee27084309f7cd02cd94cb3", +// "ObjectID": "f6dbf411da22e67d74cd7ddba6a76cd7e14a4822:85" +// } +// } +// } // switchrealm["gno.land/r/demo/boards"] // switchrealm["gno.land/r/demo/users"] // switchrealm["gno.land/r/demo/users"] diff --git a/examples/gno.land/r/demo/btree_dao/btree_dao.gno b/examples/gno.land/r/demo/btree_dao/btree_dao.gno new file mode 100644 index 00000000000..c90742eb29b --- /dev/null +++ b/examples/gno.land/r/demo/btree_dao/btree_dao.gno @@ -0,0 +1,209 @@ +package btree_dao + +import ( + "errors" + "std" + "strings" + "time" + + "gno.land/p/demo/btree" + "gno.land/p/demo/grc/grc721" + "gno.land/p/demo/ufmt" + "gno.land/p/moul/md" +) + +// RegistrationDetails holds the details of a user's registration in the BTree DAO. +// It stores the user's address, registration time, their B-Tree if they planted one, +// and their NFT ID. +type RegistrationDetails struct { + Address std.Address + RegTime time.Time + UserBTree *btree.BTree + NFTID string +} + +// Less implements the btree.Record interface for RegistrationDetails. +// It compares two RegistrationDetails based on their registration time. +// Returns true if the current registration time is before the other registration time. +func (rd *RegistrationDetails) Less(than btree.Record) bool { + other := than.(*RegistrationDetails) + return rd.RegTime.Before(other.RegTime) +} + +var ( + dao = grc721.NewBasicNFT("BTree DAO", "BTDAO") + tokenID = 0 + members = btree.New() +) + +// PlantTree allows a user to plant their B-Tree in the DAO forest. +// It mints an NFT to the user and registers their tree in the DAO. +// Returns an error if the tree is already planted, empty, or if NFT minting fails. +func PlantTree(userBTree *btree.BTree) error { + return plantImpl(userBTree, "") +} + +// PlantSeed allows a user to register as a seed in the DAO with a message. +// It mints an NFT to the user and registers them as a seed member. +// Returns an error if the message is empty or if NFT minting fails. +func PlantSeed(message string) error { + return plantImpl(nil, message) +} + +// plantImpl is the internal implementation that handles both tree planting and seed registration. +// For tree planting (userBTree != nil), it verifies the tree isn't already planted and isn't empty. +// For seed planting (userBTree == nil), it verifies the seed message isn't empty. +// In both cases, it mints an NFT to the user and adds their registration details to the members tree. +// Returns an error if any validation fails or if NFT minting fails. +func plantImpl(userBTree *btree.BTree, seedMessage string) error { + // Get the caller's address + userAddress := std.GetOrigCaller() + + var nftID string + var regDetails *RegistrationDetails + + if userBTree != nil { + // Handle tree planting + var treeExists bool + members.Ascend(func(record btree.Record) bool { + regDetails := record.(*RegistrationDetails) + if regDetails.UserBTree == userBTree { + treeExists = true + return false + } + return true + }) + if treeExists { + return errors.New("tree is already planted in the forest") + } + + if userBTree.Len() == 0 { + return errors.New("cannot plant an empty tree") + } + + nftID = ufmt.Sprintf("%d", tokenID) + regDetails = &RegistrationDetails{ + Address: userAddress, + RegTime: time.Now(), + UserBTree: userBTree, + NFTID: nftID, + } + } else { + // Handle seed planting + if seedMessage == "" { + return errors.New("seed message cannot be empty") + } + nftID = "seed_" + ufmt.Sprintf("%d", tokenID) + regDetails = &RegistrationDetails{ + Address: userAddress, + RegTime: time.Now(), + UserBTree: nil, + NFTID: nftID, + } + } + + // Mint an NFT to the user + err := dao.Mint(userAddress, grc721.TokenID(nftID)) + if err != nil { + return err + } + + members.Insert(regDetails) + tokenID++ + return nil +} + +// Render generates a Markdown representation of the DAO members. +// It displays: +// - Total number of NFTs minted +// - Total number of members +// - Size of the biggest planted tree +// - The first 3 members (OGs) +// - The latest 10 members +// Each member entry includes their address and owned NFTs (đŸŒŗ for trees, 🌱 for seeds). +// The path parameter is currently unused. +// Returns a formatted Markdown string. +func Render(path string) string { + var latestMembers []string + var ogMembers []string + + // Get total size and first member + totalSize := members.Len() + biggestTree := 0 + if maxMember := members.Max(); maxMember != nil { + if userBTree := maxMember.(*RegistrationDetails).UserBTree; userBTree != nil { + biggestTree = userBTree.Len() + } + } + + // Collect the latest 10 members + members.Descend(func(record btree.Record) bool { + if len(latestMembers) < 10 { + regDetails := record.(*RegistrationDetails) + addr := regDetails.Address + nftList := "" + balance, err := dao.BalanceOf(addr) + if err == nil && balance > 0 { + nftList = " (NFTs: " + for i := uint64(0); i < balance; i++ { + if i > 0 { + nftList += ", " + } + if regDetails.UserBTree == nil { + nftList += "🌱#" + regDetails.NFTID + } else { + nftList += "đŸŒŗ#" + regDetails.NFTID + } + } + nftList += ")" + } + latestMembers = append(latestMembers, string(addr)+nftList) + return true + } + return false + }) + + // Collect the first 3 members (OGs) + members.Ascend(func(record btree.Record) bool { + if len(ogMembers) < 3 { + regDetails := record.(*RegistrationDetails) + addr := regDetails.Address + nftList := "" + balance, err := dao.BalanceOf(addr) + if err == nil && balance > 0 { + nftList = " (NFTs: " + for i := uint64(0); i < balance; i++ { + if i > 0 { + nftList += ", " + } + if regDetails.UserBTree == nil { + nftList += "🌱#" + regDetails.NFTID + } else { + nftList += "đŸŒŗ#" + regDetails.NFTID + } + } + nftList += ")" + } + ogMembers = append(ogMembers, string(addr)+nftList) + return true + } + return false + }) + + var sb strings.Builder + + sb.WriteString(md.H1("B-Tree DAO Members")) + sb.WriteString(md.H2("Total NFTs Minted")) + sb.WriteString(ufmt.Sprintf("Total NFTs minted: %d\n\n", dao.TokenCount())) + sb.WriteString(md.H2("Member Stats")) + sb.WriteString(ufmt.Sprintf("Total members: %d\n", totalSize)) + if biggestTree > 0 { + sb.WriteString(ufmt.Sprintf("Biggest tree size: %d\n", biggestTree)) + } + sb.WriteString(md.H2("OG Members")) + sb.WriteString(md.BulletList(ogMembers)) + sb.WriteString(md.H2("Latest Members")) + sb.WriteString(md.BulletList(latestMembers)) + + return sb.String() +} diff --git a/examples/gno.land/r/demo/btree_dao/btree_dao_test.gno b/examples/gno.land/r/demo/btree_dao/btree_dao_test.gno new file mode 100644 index 00000000000..0514f52f7b4 --- /dev/null +++ b/examples/gno.land/r/demo/btree_dao/btree_dao_test.gno @@ -0,0 +1,97 @@ +package btree_dao + +import ( + "std" + "strings" + "testing" + "time" + + "gno.land/p/demo/btree" + "gno.land/p/demo/uassert" + "gno.land/p/demo/urequire" +) + +func setupTest() { + std.TestSetOrigCaller(std.Address("g1ej0qca5ptsw9kfr64ey8jvfy9eacga6mpj2z0y")) + members = btree.New() +} + +type TestElement struct { + value int +} + +func (te *TestElement) Less(than btree.Record) bool { + return te.value < than.(*TestElement).value +} + +func TestPlantTree(t *testing.T) { + setupTest() + + tree := btree.New() + elements := []int{30, 10, 50, 20, 40} + for _, val := range elements { + tree.Insert(&TestElement{value: val}) + } + + err := PlantTree(tree) + urequire.NoError(t, err) + + found := false + members.Ascend(func(record btree.Record) bool { + regDetails := record.(*RegistrationDetails) + if regDetails.UserBTree == tree { + found = true + return false + } + return true + }) + uassert.True(t, found) + + err = PlantTree(tree) + uassert.Error(t, err) + + emptyTree := btree.New() + err = PlantTree(emptyTree) + uassert.Error(t, err) +} + +func TestPlantSeed(t *testing.T) { + setupTest() + + err := PlantSeed("Hello DAO!") + urequire.NoError(t, err) + + found := false + members.Ascend(func(record btree.Record) bool { + regDetails := record.(*RegistrationDetails) + if regDetails.UserBTree == nil { + found = true + uassert.NotEmpty(t, regDetails.NFTID) + uassert.True(t, strings.Contains(regDetails.NFTID, "seed_")) + return false + } + return true + }) + uassert.True(t, found) + + err = PlantSeed("") + uassert.Error(t, err) +} + +func TestRegistrationDetailsOrdering(t *testing.T) { + setupTest() + + rd1 := &RegistrationDetails{ + Address: std.Address("test1"), + RegTime: time.Now(), + NFTID: "0", + } + rd2 := &RegistrationDetails{ + Address: std.Address("test2"), + RegTime: time.Now().Add(time.Hour), + NFTID: "1", + } + + uassert.True(t, rd1.Less(rd2)) + uassert.False(t, rd2.Less(rd1)) +} diff --git a/examples/gno.land/r/demo/btree_dao/gno.mod b/examples/gno.land/r/demo/btree_dao/gno.mod new file mode 100644 index 00000000000..01b99acc300 --- /dev/null +++ b/examples/gno.land/r/demo/btree_dao/gno.mod @@ -0,0 +1 @@ +module gno.land/r/demo/btree_dao diff --git a/examples/gno.land/r/demo/foo20/foo20.gno b/examples/gno.land/r/demo/foo20/foo20.gno index 97b2e52b94b..6522fbdc90e 100644 --- a/examples/gno.land/r/demo/foo20/foo20.gno +++ b/examples/gno.land/r/demo/foo20/foo20.gno @@ -17,13 +17,12 @@ import ( var ( Token, privateLedger = grc20.NewToken("Foo", "FOO", 4) UserTeller = Token.CallerTeller() - owner = ownable.NewWithAddress("g1manfred47kzduec920z88wfr64ylksmdcedlf5") // @manfred + Ownable = ownable.NewWithAddress("g1manfred47kzduec920z88wfr64ylksmdcedlf5") // @manfred ) func init() { - privateLedger.Mint(owner.Owner(), 1_000_000*10_000) // @privateLedgeristrator (1M) - getter := func() *grc20.Token { return Token } - grc20reg.Register(getter, "") + privateLedger.Mint(Ownable.Owner(), 1_000_000*10_000) // @privateLedgeristrator (1M) + grc20reg.Register(Token.Getter(), "") } func TotalSupply() uint64 { @@ -66,13 +65,13 @@ func Faucet() { } func Mint(to pusers.AddressOrName, amount uint64) { - owner.AssertCallerIsOwner() + Ownable.AssertCallerIsOwner() toAddr := users.Resolve(to) checkErr(privateLedger.Mint(toAddr, amount)) } func Burn(from pusers.AddressOrName, amount uint64) { - owner.AssertCallerIsOwner() + Ownable.AssertCallerIsOwner() fromAddr := users.Resolve(from) checkErr(privateLedger.Burn(fromAddr, amount)) } diff --git a/examples/gno.land/r/demo/grc20factory/grc20factory.gno b/examples/gno.land/r/demo/grc20factory/grc20factory.gno index cfd32479f9d..aa91084ab32 100644 --- a/examples/gno.land/r/demo/grc20factory/grc20factory.gno +++ b/examples/gno.land/r/demo/grc20factory/grc20factory.gno @@ -43,8 +43,7 @@ func NewWithAdmin(name, symbol string, decimals uint, initialMint, faucet uint64 faucet: faucet, } instances.Set(symbol, &inst) - getter := func() *grc20.Token { return token } - grc20reg.Register(getter, symbol) + grc20reg.Register(token.Getter(), symbol) } func (inst instance) Token() *grc20.Token { @@ -120,6 +119,17 @@ func Burn(symbol string, from std.Address, amount uint64) { checkErr(inst.ledger.Burn(from, amount)) } +// instance admin functionality +func DropInstanceOwnership(symbol string) { + inst := mustGetInstance(symbol) + checkErr(inst.admin.DropOwnership()) +} + +func TransferInstanceOwnership(symbol string, newOwner std.Address) { + inst := mustGetInstance(symbol) + checkErr(inst.admin.TransferOwnership(newOwner)) +} + func Render(path string) string { parts := strings.Split(path, "/") c := len(parts) diff --git a/examples/gno.land/r/demo/keystore/keystore_test.gno b/examples/gno.land/r/demo/keystore/keystore_test.gno index ffd8e60936f..9b5fafa2f95 100644 --- a/examples/gno.land/r/demo/keystore/keystore_test.gno +++ b/examples/gno.land/r/demo/keystore/keystore_test.gno @@ -11,7 +11,7 @@ import ( ) func TestRender(t *testing.T) { - const ( + var ( author1 std.Address = testutils.TestAddress("author1") author2 std.Address = testutils.TestAddress("author2") ) diff --git a/examples/gno.land/r/demo/microblog/microblog_test.gno b/examples/gno.land/r/demo/microblog/microblog_test.gno index a3c8f04ee7f..9ad98d3cbfe 100644 --- a/examples/gno.land/r/demo/microblog/microblog_test.gno +++ b/examples/gno.land/r/demo/microblog/microblog_test.gno @@ -10,7 +10,7 @@ import ( ) func TestMicroblog(t *testing.T) { - const ( + var ( author1 std.Address = testutils.TestAddress("author1") author2 std.Address = testutils.TestAddress("author2") ) diff --git a/examples/gno.land/r/demo/tests/test20/gno.mod b/examples/gno.land/r/demo/tests/test20/gno.mod new file mode 100644 index 00000000000..7a71668d2df --- /dev/null +++ b/examples/gno.land/r/demo/tests/test20/gno.mod @@ -0,0 +1 @@ +module gno.land/r/demo/tests/test20 diff --git a/examples/gno.land/r/demo/tests/test20/test20.gno b/examples/gno.land/r/demo/tests/test20/test20.gno new file mode 100644 index 00000000000..9c4df58d1c4 --- /dev/null +++ b/examples/gno.land/r/demo/tests/test20/test20.gno @@ -0,0 +1,20 @@ +// Package test20 implements a deliberately insecure ERC20 token for testing purposes. +// The Test20 token allows anyone to mint any amount of tokens to any address, making +// it unsuitable for production use. The primary goal of this package is to facilitate +// testing and experimentation without any security measures or restrictions. +// +// WARNING: This token is highly insecure and should not be used in any +// production environment. It is intended solely for testing and +// educational purposes. +package test20 + +import ( + "gno.land/p/demo/grc/grc20" + "gno.land/r/demo/grc20reg" +) + +var Token, PrivateLedger = grc20.NewToken("Test20", "TST", 4) + +func init() { + grc20reg.Register(Token.Getter(), "") +} diff --git a/examples/gno.land/r/demo/users/users.gno b/examples/gno.land/r/demo/users/users.gno index 8547a6e60e0..451afc7bf96 100644 --- a/examples/gno.land/r/demo/users/users.gno +++ b/examples/gno.land/r/demo/users/users.gno @@ -260,7 +260,7 @@ func GetUserByAddressOrName(input users.AddressOrName) *users.User { // Get a list of user names starting from the given prefix. Limit the // number of results to maxResults. (This can be used for a name search tool.) func ListUsersByPrefix(prefix string, maxResults int) []string { - return avlhelpers.ListByteStringKeysByPrefix(name2User, prefix, maxResults) + return avlhelpers.ListByteStringKeysByPrefix(&name2User, prefix, maxResults) } func Resolve(input users.AddressOrName) std.Address { diff --git a/examples/gno.land/r/demo/users/z_13_filetest.gno b/examples/gno.land/r/demo/users/z_13_filetest.gno new file mode 100644 index 00000000000..6ef312dc41c --- /dev/null +++ b/examples/gno.land/r/demo/users/z_13_filetest.gno @@ -0,0 +1,22 @@ +package main + +// SEND: 200000000ugnot + +import ( + "strconv" + + "gno.land/r/demo/users" +) + +func main() { + { + // Verify pre-registered test1 user + names := users.ListUsersByPrefix("test1", 1) + println("# names: " + strconv.Itoa(len(names))) + println("name: " + names[0]) + } +} + +// Output: +// # names: 1 +// name: test1 diff --git a/examples/gno.land/r/demo/wugnot/wugnot.gno b/examples/gno.land/r/demo/wugnot/wugnot.gno index 09538b860ca..b72f5161e7d 100644 --- a/examples/gno.land/r/demo/wugnot/wugnot.gno +++ b/examples/gno.land/r/demo/wugnot/wugnot.gno @@ -19,8 +19,7 @@ const ( ) func init() { - getter := func() *grc20.Token { return Token } - grc20reg.Register(getter, "") + grc20reg.Register(Token.Getter(), "") } func Deposit() { diff --git a/examples/gno.land/r/gnoland/events/administration.gno b/examples/gno.land/r/gnoland/events/administration.gno deleted file mode 100644 index 02914adee69..00000000000 --- a/examples/gno.land/r/gnoland/events/administration.gno +++ /dev/null @@ -1,26 +0,0 @@ -package events - -import ( - "std" - - "gno.land/p/demo/ownable/exts/authorizable" -) - -var ( - su = std.Address("g125em6arxsnj49vx35f0n0z34putv5ty3376fg5") // @leohhhn - auth = authorizable.NewAuthorizableWithAddress(su) -) - -// GetOwner gets the owner of the events realm -func GetOwner() std.Address { - return auth.Owner() -} - -// AddModerator adds a moderator to the events realm -func AddModerator(mod std.Address) { - auth.AssertCallerIsOwner() - - if err := auth.AddToAuthList(mod); err != nil { - panic(err) - } -} diff --git a/examples/gno.land/r/gnoland/events/events.gno b/examples/gno.land/r/gnoland/events/events.gno index baf9ba3d4af..d72638ceaaf 100644 --- a/examples/gno.land/r/gnoland/events/events.gno +++ b/examples/gno.land/r/gnoland/events/events.gno @@ -9,6 +9,7 @@ import ( "strings" "time" + "gno.land/p/demo/ownable/exts/authorizable" "gno.land/p/demo/seqid" "gno.land/p/demo/ufmt" ) @@ -28,6 +29,9 @@ type ( ) var ( + su = std.Address("g125em6arxsnj49vx35f0n0z34putv5ty3376fg5") // @leohhhn + Auth = authorizable.NewAuthorizableWithAddress(su) + events = make(eventsSlice, 0) // sorted idCounter seqid.ID ) @@ -42,7 +46,7 @@ const ( // AddEvent adds auth new event // Start time & end time need to be specified in RFC3339, ie 2024-08-08T12:00:00+02:00 func AddEvent(name, description, link, location, startTime, endTime string) (string, error) { - auth.AssertOnAuthList() + Auth.AssertOnAuthList() if strings.TrimSpace(name) == "" { return "", ErrEmptyName @@ -81,7 +85,7 @@ func AddEvent(name, description, link, location, startTime, endTime string) (str // DeleteEvent deletes an event with auth given ID func DeleteEvent(id string) { - auth.AssertOnAuthList() + Auth.AssertOnAuthList() e, idx, err := GetEventByID(id) if err != nil { @@ -99,7 +103,7 @@ func DeleteEvent(id string) { // It only updates values corresponding to non-empty arguments sent with the call // Note: if you need to update the start time or end time, you need to provide both every time func EditEvent(id string, name, description, link, location, startTime, endTime string) { - auth.AssertOnAuthList() + Auth.AssertOnAuthList() e, _, err := GetEventByID(id) if err != nil { diff --git a/examples/gno.land/r/gnoland/events/rendering.gno b/examples/gno.land/r/gnoland/events/render.gno similarity index 100% rename from examples/gno.land/r/gnoland/events/rendering.gno rename to examples/gno.land/r/gnoland/events/render.gno diff --git a/examples/gno.land/r/gnoland/home/home.gno b/examples/gno.land/r/gnoland/home/home.gno index facb1817fe2..2d1aad8a1a0 100644 --- a/examples/gno.land/r/gnoland/home/home.gno +++ b/examples/gno.land/r/gnoland/home/home.gno @@ -70,14 +70,14 @@ func Render(_ string) string { func lastBlogposts(limit int) ui.Element { posts := blog.RenderLastPostsWidget(limit) return ui.Element{ - ui.H3("[Latest Blogposts](/r/gnoland/blog)"), + ui.H2("[Latest Blogposts](/r/gnoland/blog)"), ui.Text(posts), } } func lastContributions(limit int) ui.Element { return ui.Element{ - ui.H3("Latest Contributions"), + ui.H2("Latest Contributions"), // TODO: import r/gh to ui.Link{Text: "View latest contributions", URL: "https://github.com/gnolang/gno/pulls"}, } @@ -86,7 +86,7 @@ func lastContributions(limit int) ui.Element { func upcomingEvents() ui.Element { out, _ := events.RenderEventWidget(events.MaxWidgetSize) return ui.Element{ - ui.H3("[Latest Events](/r/gnoland/events)"), + ui.H2("[Latest Events](/r/gnoland/events)"), ui.Text(out), } } @@ -95,14 +95,14 @@ func latestHOFItems(num int) ui.Element { submissions := hof.RenderExhibWidget(num) return ui.Element{ - ui.H3("[Hall of Fame](/r/leon/hof)"), + ui.H2("[Hall of Fame](/r/leon/hof)"), ui.Text(submissions), } } func introSection() ui.Element { return ui.Element{ - ui.H3("We’re building gno.land, set to become the leading open-source smart contract platform, using Gno, an interpreted and fully deterministic variation of the Go programming language for succinct and composable smart contracts."), + ui.Text("**We’re building gno.land, set to become the leading open-source smart contract platform, using Gno, an interpreted and fully deterministic variation of the Go programming language for succinct and composable smart contracts.**"), ui.Paragraph("With transparent and timeless code, gno.land is the next generation of smart contract platforms, serving as the “GitHub” of the ecosystem, with realms built using fully transparent, auditable code that anyone can inspect and reuse."), ui.Paragraph("Intuitive and easy to use, gno.land lowers the barrier to web3 and makes censorship-resistant platforms accessible to everyone. If you want to help lay the foundations of a fairer and freer world, join us today."), } @@ -135,7 +135,7 @@ func worxDAO() ui.Element { ## Contributors ``*/ return ui.Element{ - ui.H3("Contributions (WorxDAO & GoR)"), + ui.H2("Contributions (WorxDAO & GoR)"), // TODO: GoR dashboard + WorxDAO topics ui.Text(`coming soon`), } @@ -154,28 +154,28 @@ func quoteOfTheBlock() ui.Element { qotb := quotes[idx] return ui.Element{ - ui.H3(ufmt.Sprintf("Quote of the ~Day~ Block#%d", height)), + ui.H2(ufmt.Sprintf("Quote of the ~Day~ Block#%d", height)), ui.Quote(qotb), } } func socialLinks() ui.Element { return ui.Element{ - ui.H3("Socials"), + ui.H2("Socials"), ui.BulletList{ // XXX: improve UI to support a nice GO api for such links ui.Text("Check out our [community projects](https://github.com/gnolang/awesome-gno)"), - ui.Text("![Discord](static/img/ico-discord.svg) [Discord](https://discord.gg/S8nKUqwkPn)"), - ui.Text("![Twitter](static/img/ico-twitter.svg) [Twitter](https://twitter.com/_gnoland)"), - ui.Text("![Youtube](static/img/ico-youtube.svg) [Youtube](https://www.youtube.com/@_gnoland)"), - ui.Text("![Telegram](static/img/ico-telegram.svg) [Telegram](https://t.me/gnoland)"), + ui.Text("[Discord](https://discord.gg/S8nKUqwkPn)"), + ui.Text("[Twitter](https://twitter.com/_gnoland)"), + ui.Text("[Youtube](https://www.youtube.com/@_gnoland)"), + ui.Text("[Telegram](https://t.me/gnoland)"), }, } } func playgroundSection() ui.Element { return ui.Element{ - ui.H3("[Gno Playground](https://play.gno.land)"), + ui.H2("[Gno Playground](https://play.gno.land)"), ui.Paragraph(`Gno Playground is a web application designed for building, running, testing, and interacting with your Gno code, enhancing your understanding of the Gno language. With Gno Playground, you can share your code, execute tests, deploy your realms and packages to gno.land, and explore a multitude of other features.`), @@ -186,12 +186,12 @@ execute tests, deploy your realms and packages to gno.land, and explore a multit func packageStaffPicks() ui.Element { // XXX: make it modifiable from a DAO return ui.Element{ - ui.H3("Explore New Packages and Realms"), + ui.H2("Explore New Packages and Realms"), ui.Columns{ 3, []ui.Element{ { - ui.H4("[r/gnoland](https://github.com/gnolang/gno/tree/master/examples/gno.land/r/gnoland)"), + ui.H3("[r/gnoland](https://github.com/gnolang/gno/tree/master/examples/gno.land/r/gnoland)"), ui.BulletList{ ui.Link{URL: "r/gnoland/blog"}, ui.Link{URL: "r/gnoland/dao"}, @@ -199,14 +199,14 @@ func packageStaffPicks() ui.Element { ui.Link{URL: "r/gnoland/home"}, ui.Link{URL: "r/gnoland/pages"}, }, - ui.H4("[r/sys](https://github.com/gnolang/gno/tree/master/examples/gno.land/r/sys)"), + ui.H3("[r/sys](https://github.com/gnolang/gno/tree/master/examples/gno.land/r/sys)"), ui.BulletList{ ui.Link{URL: "r/sys/names"}, ui.Link{URL: "r/sys/rewards"}, ui.Link{URL: "/r/sys/validators/v2"}, }, }, { - ui.H4("[r/demo](https://github.com/gnolang/gno/tree/master/examples/gno.land/r/demo)"), + ui.H3("[r/demo](https://github.com/gnolang/gno/tree/master/examples/gno.land/r/demo)"), ui.BulletList{ ui.Link{URL: "r/demo/boards"}, ui.Link{URL: "r/demo/users"}, @@ -222,7 +222,7 @@ func packageStaffPicks() ui.Element { ui.Text("..."), }, }, { - ui.H4("[p/demo](https://github.com/gnolang/gno/tree/master/examples/gno.land/p/demo)"), + ui.H3("[p/demo](https://github.com/gnolang/gno/tree/master/examples/gno.land/p/demo)"), ui.BulletList{ ui.Link{URL: "p/demo/avl"}, ui.Link{URL: "p/demo/blog"}, @@ -247,7 +247,7 @@ func discoverLinks() ui.Element { ui.Text(`
-### Learn about gno.land +## Learn about gno.land - [About](/about) - [GitHub](https://github.com/gnolang) @@ -262,7 +262,7 @@ func discoverLinks() ui.Element {
-### Build with Gno +## Build with Gno - [Write Gno in the browser](https://play.gno.land) - [Read about the Gno Language](/gnolang) @@ -274,7 +274,7 @@ func discoverLinks() ui.Element {
-### Explore the universe +## Explore the universe - [Discover demo packages](https://github.com/gnolang/gno/tree/master/examples) - [Gnoscan](https://gnoscan.io) diff --git a/examples/gno.land/r/gnoland/home/home_filetest.gno b/examples/gno.land/r/gnoland/home/home_filetest.gno index 4825c9fc588..5b5ff5740c3 100644 --- a/examples/gno.land/r/gnoland/home/home_filetest.gno +++ b/examples/gno.land/r/gnoland/home/home_filetest.gno @@ -11,8 +11,7 @@ func main() { // // # Welcome to gno.land // -// ### We’re building gno.land, set to become the leading open-source smart contract platform, using Gno, an interpreted and fully deterministic variation of the Go programming language for succinct and composable smart contracts. -// +// **We’re building gno.land, set to become the leading open-source smart contract platform, using Gno, an interpreted and fully deterministic variation of the Go programming language for succinct and composable smart contracts.** // // With transparent and timeless code, gno.land is the next generation of smart contract platforms, serving as the “GitHub” of the ecosystem, with realms built using fully transparent, auditable code that anyone can inspect and reuse. // @@ -24,7 +23,7 @@ func main() { //
//
// -// ### Learn about gno.land +// ## Learn about gno.land // // - [About](/about) // - [GitHub](https://github.com/gnolang) @@ -39,7 +38,7 @@ func main() { // //
// -// ### Build with Gno +// ## Build with Gno // // - [Write Gno in the browser](https://play.gno.land) // - [Read about the Gno Language](/gnolang) @@ -51,7 +50,7 @@ func main() { //
//
// -// ### Explore the universe +// ## Explore the universe // // - [Discover demo packages](https://github.com/gnolang/gno/tree/master/examples) // - [Gnoscan](https://gnoscan.io) @@ -66,19 +65,19 @@ func main() { //
//
// -// ### [Latest Blogposts](/r/gnoland/blog) +// ## [Latest Blogposts](/r/gnoland/blog) // // No posts. //
//
// -// ### [Latest Events](/r/gnoland/events) +// ## [Latest Events](/r/gnoland/events) // // No events. //
//
// -// ### [Hall of Fame](/r/leon/hof) +// ## [Hall of Fame](/r/leon/hof) // // //
@@ -87,7 +86,7 @@ func main() { // // --- // -// ### [Gno Playground](https://play.gno.land) +// ## [Gno Playground](https://play.gno.land) // // // Gno Playground is a web application designed for building, running, testing, and interacting @@ -100,12 +99,12 @@ func main() { // // --- // -// ### Explore New Packages and Realms +// ## Explore New Packages and Realms // //
//
// -// #### [r/gnoland](https://github.com/gnolang/gno/tree/master/examples/gno.land/r/gnoland) +// ### [r/gnoland](https://github.com/gnolang/gno/tree/master/examples/gno.land/r/gnoland) // // - [r/gnoland/blog](r/gnoland/blog) // - [r/gnoland/dao](r/gnoland/dao) @@ -113,7 +112,7 @@ func main() { // - [r/gnoland/home](r/gnoland/home) // - [r/gnoland/pages](r/gnoland/pages) // -// #### [r/sys](https://github.com/gnolang/gno/tree/master/examples/gno.land/r/sys) +// ### [r/sys](https://github.com/gnolang/gno/tree/master/examples/gno.land/r/sys) // // - [r/sys/names](r/sys/names) // - [r/sys/rewards](r/sys/rewards) @@ -122,7 +121,7 @@ func main() { //
//
// -// #### [r/demo](https://github.com/gnolang/gno/tree/master/examples/gno.land/r/demo) +// ### [r/demo](https://github.com/gnolang/gno/tree/master/examples/gno.land/r/demo) // // - [r/demo/boards](r/demo/boards) // - [r/demo/users](r/demo/users) @@ -140,7 +139,7 @@ func main() { //
//
// -// #### [p/demo](https://github.com/gnolang/gno/tree/master/examples/gno.land/p/demo) +// ### [p/demo](https://github.com/gnolang/gno/tree/master/examples/gno.land/p/demo) // // - [p/demo/avl](p/demo/avl) // - [p/demo/blog](p/demo/blog) @@ -160,7 +159,7 @@ func main() { // // --- // -// ### Contributions (WorxDAO & GoR) +// ## Contributions (WorxDAO & GoR) // // coming soon // @@ -170,18 +169,18 @@ func main() { //
//
// -// ### Socials +// ## Socials // // - Check out our [community projects](https://github.com/gnolang/awesome-gno) -// - ![Discord](static/img/ico-discord.svg) [Discord](https://discord.gg/S8nKUqwkPn) -// - ![Twitter](static/img/ico-twitter.svg) [Twitter](https://twitter.com/_gnoland) -// - ![Youtube](static/img/ico-youtube.svg) [Youtube](https://www.youtube.com/@_gnoland) -// - ![Telegram](static/img/ico-telegram.svg) [Telegram](https://t.me/gnoland) +// - [Discord](https://discord.gg/S8nKUqwkPn) +// - [Twitter](https://twitter.com/_gnoland) +// - [Youtube](https://www.youtube.com/@_gnoland) +// - [Telegram](https://t.me/gnoland) // //
//
// -// ### Quote of the ~Day~ Block#123 +// ## Quote of the ~Day~ Block#123 // // > Now, you Gno. // diff --git a/examples/gno.land/r/gnoland/monit/monit.gno b/examples/gno.land/r/gnoland/monit/monit.gno index 8747ea582b3..be94fbdd2bb 100644 --- a/examples/gno.land/r/gnoland/monit/monit.gno +++ b/examples/gno.land/r/gnoland/monit/monit.gno @@ -20,7 +20,7 @@ var ( lastUpdate time.Time lastCaller std.Address wd = watchdog.Watchdog{Duration: 5 * time.Minute} - owner = ownable.New() // TODO: replace with -> ownable.NewWithAddress... + Ownable = ownable.New() // TODO: replace with -> ownable.NewWithAddress... watchdogDuration = 5 * time.Minute ) @@ -37,9 +37,8 @@ func Incr() int { // Reset resets the realm state. // This function can only be called by the admin. func Reset() { - if owner.CallerIsOwner() != nil { // TODO: replace with owner.AssertCallerIsOwner - panic("unauthorized") - } + Ownable.AssertCallerIsOwner() + counter = 0 lastCaller = std.PrevRealm().Addr() lastUpdate = time.Now() @@ -53,7 +52,3 @@ func Render(_ string) string { counter, lastUpdate, lastCaller, status, ) } - -// TransferOwnership transfers ownership to a new owner. This is a proxy to -// ownable.Ownable.TransferOwnership. -func TransferOwnership(newOwner std.Address) { owner.TransferOwnership(newOwner) } diff --git a/examples/gno.land/r/leon/hof/administration.gno b/examples/gno.land/r/leon/hof/administration.gno deleted file mode 100644 index 4b5b212eddf..00000000000 --- a/examples/gno.land/r/leon/hof/administration.gno +++ /dev/null @@ -1,24 +0,0 @@ -package hof - -import "std" - -// Exposing the ownable & pausable APIs -// Should not be needed as soon as MsgCall supports calling methods on exported variables - -func Pause() error { - return exhibition.Pause() -} - -func Unpause() error { - return exhibition.Unpause() -} - -func GetOwner() std.Address { - return owner.Owner() -} - -func TransferOwnership(newOwner std.Address) { - if err := owner.TransferOwnership(newOwner); err != nil { - panic(err) - } -} diff --git a/examples/gno.land/r/leon/hof/datasource.gno b/examples/gno.land/r/leon/hof/datasource.gno new file mode 100644 index 00000000000..180c4880177 --- /dev/null +++ b/examples/gno.land/r/leon/hof/datasource.gno @@ -0,0 +1,77 @@ +package hof + +import ( + "errors" + + "gno.land/p/demo/avl" + "gno.land/p/demo/ufmt" + "gno.land/p/jeronimoalbi/datasource" +) + +func NewDatasource() Datasource { + return Datasource{exhibition} +} + +type Datasource struct { + exhibition *Exhibition +} + +func (ds Datasource) Size() int { return ds.exhibition.itemsSorted.Size() } + +func (ds Datasource) Records(q datasource.Query) datasource.Iterator { + return &iterator{ + exhibition: ds.exhibition, + index: q.Offset, + maxIndex: q.Offset + q.Count, + } +} + +func (ds Datasource) Record(id string) (datasource.Record, error) { + v, found := ds.exhibition.itemsSorted.Get(id) + if !found { + return nil, errors.New("realm submission not found") + } + return record{v.(*Item)}, nil +} + +type record struct { + item *Item +} + +func (r record) ID() string { return r.item.id.String() } +func (r record) String() string { return r.item.pkgpath } + +func (r record) Fields() (datasource.Fields, error) { + fields := avl.NewTree() + fields.Set( + "details", + ufmt.Sprintf("Votes: âļ %d - ⏷ %d", r.item.upvote.Size(), r.item.downvote.Size()), + ) + return fields, nil +} + +func (r record) Content() (string, error) { + content := ufmt.Sprintf("# Submission #%d\n\n", int(r.item.id)) + content += r.item.Render(false) + return content, nil +} + +type iterator struct { + exhibition *Exhibition + index, maxIndex int + record *record +} + +func (it iterator) Record() datasource.Record { return it.record } +func (it iterator) Err() error { return nil } + +func (it *iterator) Next() bool { + if it.index >= it.maxIndex || it.index >= it.exhibition.itemsSorted.Size() { + return false + } + + _, v := it.exhibition.itemsSorted.GetByIndex(it.index) + it.record = &record{v.(*Item)} + it.index++ + return true +} diff --git a/examples/gno.land/r/leon/hof/datasource_test.gno b/examples/gno.land/r/leon/hof/datasource_test.gno new file mode 100644 index 00000000000..376f981875f --- /dev/null +++ b/examples/gno.land/r/leon/hof/datasource_test.gno @@ -0,0 +1,157 @@ +package hof + +import ( + "testing" + + "gno.land/p/demo/avl" + "gno.land/p/demo/uassert" + "gno.land/p/demo/urequire" + "gno.land/p/jeronimoalbi/datasource" +) + +var ( + _ datasource.Datasource = (*Datasource)(nil) + _ datasource.Record = (*record)(nil) + _ datasource.ContentRecord = (*record)(nil) + _ datasource.Iterator = (*iterator)(nil) +) + +func TestDatasourceRecords(t *testing.T) { + cases := []struct { + name string + items []*Item + recordIDs []string + options []datasource.QueryOption + }{ + { + name: "all items", + items: []*Item{{id: 1}, {id: 2}, {id: 3}}, + recordIDs: []string{"0000001", "0000002", "0000003"}, + }, + { + name: "with offset", + items: []*Item{{id: 1}, {id: 2}, {id: 3}}, + recordIDs: []string{"0000002", "0000003"}, + options: []datasource.QueryOption{datasource.WithOffset(1)}, + }, + { + name: "with count", + items: []*Item{{id: 1}, {id: 2}, {id: 3}}, + recordIDs: []string{"0000001", "0000002"}, + options: []datasource.QueryOption{datasource.WithCount(2)}, + }, + { + name: "with offset and count", + items: []*Item{{id: 1}, {id: 2}, {id: 3}}, + recordIDs: []string{"0000002"}, + options: []datasource.QueryOption{ + datasource.WithOffset(1), + datasource.WithCount(1), + }, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + // Initialize a local instance of exhibition + exhibition := &Exhibition{itemsSorted: avl.NewTree()} + for _, item := range tc.items { + exhibition.itemsSorted.Set(item.id.String(), item) + } + + // Get a records iterator + ds := Datasource{exhibition} + query := datasource.NewQuery(tc.options...) + iter := ds.Records(query) + + // Start asserting + urequire.Equal(t, len(tc.items), ds.Size(), "datasource size") + + var records []datasource.Record + for iter.Next() { + records = append(records, iter.Record()) + } + urequire.Equal(t, len(tc.recordIDs), len(records), "record count") + + for i, r := range records { + uassert.Equal(t, tc.recordIDs[i], r.ID()) + } + }) + } +} + +func TestDatasourceRecord(t *testing.T) { + cases := []struct { + name string + items []*Item + id string + err string + }{ + { + name: "found", + items: []*Item{{id: 1}, {id: 2}, {id: 3}}, + id: "0000001", + }, + { + name: "no found", + items: []*Item{{id: 1}, {id: 2}, {id: 3}}, + id: "42", + err: "realm submission not found", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + // Initialize a local instance of exhibition + exhibition := &Exhibition{itemsSorted: avl.NewTree()} + for _, item := range tc.items { + exhibition.itemsSorted.Set(item.id.String(), item) + } + + // Get a single record + ds := Datasource{exhibition} + r, err := ds.Record(tc.id) + + // Start asserting + if tc.err != "" { + uassert.ErrorContains(t, err, tc.err) + return + } + + urequire.NoError(t, err, "no error") + urequire.NotEqual(t, nil, r, "record not nil") + uassert.Equal(t, tc.id, r.ID()) + }) + } +} + +func TestItemRecord(t *testing.T) { + pkgpath := "gno.land/r/demo/test" + item := Item{ + id: 1, + pkgpath: pkgpath, + blockNum: 42, + upvote: avl.NewTree(), + downvote: avl.NewTree(), + } + item.downvote.Set("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5", struct{}{}) + item.upvote.Set("g1w4ek2u33ta047h6lta047h6lta047h6ldvdwpn", struct{}{}) + item.upvote.Set("g1w4ek2u3jta047h6lta047h6lta047h6l9huexc", struct{}{}) + + r := record{&item} + + uassert.Equal(t, "0000001", r.ID()) + uassert.Equal(t, pkgpath, r.String()) + + fields, _ := r.Fields() + details, found := fields.Get("details") + urequire.True(t, found, "details field") + uassert.Equal(t, "Votes: âļ 2 - ⏷ 1", details) + + content, _ := r.Content() + wantContent := "# Submission #1\n\n\n```\ngno.land/r/demo/test\n```\n\nby demo\n\n" + + "[View realm](/r/demo/test)\n\nSubmitted at Block #42\n\n" + + "#### [2👍](/r/leon/hof$help&func=Upvote&pkgpath=gno.land/r/demo/test) - " + + "[1👎](/r/leon/hof$help&func=Downvote&pkgpath=gno.land/r/demo/test)\n\n" + uassert.Equal(t, wantContent, content) +} diff --git a/examples/gno.land/r/leon/hof/hof.gno b/examples/gno.land/r/leon/hof/hof.gno index 2722c019497..147a0dd1a95 100644 --- a/examples/gno.land/r/leon/hof/hof.gno +++ b/examples/gno.land/r/leon/hof/hof.gno @@ -14,7 +14,10 @@ import ( var ( exhibition *Exhibition - owner *ownable.Ownable + + // Safe objects + Ownable *ownable.Ownable + Pausable *pausable.Pausable ) type ( @@ -23,7 +26,6 @@ type ( description string items *avl.Tree // pkgPath > Item itemsSorted *avl.Tree // same data but sorted, storing pointers - *pausable.Pausable } Item struct { @@ -41,14 +43,14 @@ func init() { itemsSorted: avl.NewTree(), } - owner = ownable.NewWithAddress(std.Address("g125em6arxsnj49vx35f0n0z34putv5ty3376fg5")) - exhibition.Pausable = pausable.NewFromOwnable(owner) + Ownable = ownable.NewWithAddress(std.Address("g125em6arxsnj49vx35f0n0z34putv5ty3376fg5")) + Pausable = pausable.NewFromOwnable(Ownable) } // Register registers your realm to the Hall of Fame // Should be called from within code func Register() { - if exhibition.IsPaused() { + if Pausable.IsPaused() { return } @@ -113,8 +115,8 @@ func Downvote(pkgpath string) { } func Delete(pkgpath string) { - if err := owner.CallerIsOwner(); err != nil { - panic(err) + if !Ownable.CallerIsOwner() { + panic(ownable.ErrUnauthorized.Error()) } i, ok := exhibition.items.Get(pkgpath) diff --git a/examples/gno.land/r/leon/hof/hof_test.gno b/examples/gno.land/r/leon/hof/hof_test.gno index 72e8d2159be..4d6f70eab88 100644 --- a/examples/gno.land/r/leon/hof/hof_test.gno +++ b/examples/gno.land/r/leon/hof/hof_test.gno @@ -12,7 +12,7 @@ import ( const rlmPath = "gno.land/r/gnoland/home" var ( - admin = owner.Owner() + admin = Ownable.Owner() adminRealm = std.NewUserRealm(admin) alice = testutils.TestAddress("alice") ) @@ -27,7 +27,7 @@ func TestRegister(t *testing.T) { // Test register while paused std.TestSetRealm(adminRealm) - Pause() + Pausable.Pause() // Set legitimate caller std.TestSetRealm(std.NewCodeRealm(rlmPath)) @@ -37,7 +37,7 @@ func TestRegister(t *testing.T) { // Unpause std.TestSetRealm(adminRealm) - Unpause() + Pausable.Unpause() // Set legitimate caller std.TestSetRealm(std.NewCodeRealm(rlmPath)) diff --git a/examples/gno.land/r/leon/hof/render.gno b/examples/gno.land/r/leon/hof/render.gno index 0721c7d6e72..868262bedc7 100644 --- a/examples/gno.land/r/leon/hof/render.gno +++ b/examples/gno.land/r/leon/hof/render.gno @@ -80,9 +80,9 @@ func renderDashboard() string { out += "## Dashboard\n\n" out += ufmt.Sprintf("Total submissions: %d\n\n", exhibition.items.Size()) - out += ufmt.Sprintf("Exhibition admin: %s\n\n", owner.Owner().String()) + out += ufmt.Sprintf("Exhibition admin: %s\n\n", Ownable.Owner().String()) - if !exhibition.IsPaused() { + if !Pausable.IsPaused() { out += ufmt.Sprintf("[Pause exhibition](%s)\n\n", txlink.Call("Pause")) } else { out += ufmt.Sprintf("[Unpause exhibition](%s)\n\n", txlink.Call("Unpause")) diff --git a/examples/gno.land/r/n2p5/loci/gno.mod b/examples/gno.land/r/n2p5/loci/gno.mod new file mode 100644 index 00000000000..131e0d73467 --- /dev/null +++ b/examples/gno.land/r/n2p5/loci/gno.mod @@ -0,0 +1 @@ +module gno.land/r/n2p5/loci diff --git a/examples/gno.land/r/n2p5/loci/loci.gno b/examples/gno.land/r/n2p5/loci/loci.gno new file mode 100644 index 00000000000..36f282e729f --- /dev/null +++ b/examples/gno.land/r/n2p5/loci/loci.gno @@ -0,0 +1,68 @@ +package loci + +import ( + "encoding/base64" + "std" + + "gno.land/p/demo/ufmt" + "gno.land/p/n2p5/loci" +) + +var store *loci.LociStore + +func init() { + store = loci.New() +} + +// Set takes a base64 encoded string and stores it in the Loci store. +// Keyed by the address of the caller. It also emits a "set" event with +// the address of the caller. +func Set(value string) { + b, err := base64.StdEncoding.DecodeString(value) + if err != nil { + panic(err) + } + store.Set(b) + std.Emit("SetValue", "ForAddr", string(std.PrevRealm().Addr())) +} + +// Get retrieves the value stored at the provided address and +// returns it as a base64 encoded string. +func Get(addr std.Address) string { + return base64.StdEncoding.EncodeToString(store.Get(addr)) +} + +func Render(path string) string { + if path == "" { + return about + } + return renderGet(std.Address(path)) +} + +func renderGet(addr std.Address) string { + value := "```\n" + Get(addr) + "\n```" + + return ufmt.Sprintf(` +# Loci Value Viewer + +**Address:** %s + +%s + +`, addr, value) +} + +const about = ` +# Welcome to Loci + +Loci is a simple key-value store keyed by the caller's gno.land address. +Only the caller can set the value for their address, but anyone can +retrieve the value for any address. There are only two functions: Set and Get. +If you'd like to set a value, simply base64 encode any message you'd like and +it will be stored in in Loci. If you'd like to retrieve a value, simply provide +the address of the value you'd like to retrieve. + +For convenience, you can also use gnoweb to view the value for a given address, +if one exists. For instance append :g1j39fhg29uehm7twwnhvnpz3ggrm6tprhq65t0t to +this URL to view the value stored at that address. +` diff --git a/examples/gno.land/r/nemanya/config/config.gno b/examples/gno.land/r/nemanya/config/config.gno new file mode 100644 index 00000000000..795e48c94c1 --- /dev/null +++ b/examples/gno.land/r/nemanya/config/config.gno @@ -0,0 +1,63 @@ +package config + +import ( + "errors" + "std" +) + +var ( + main std.Address + backup std.Address + + ErrInvalidAddr = errors.New("Invalid address") + ErrUnauthorized = errors.New("Unauthorized") +) + +func init() { + main = "g1x9qyf6f34v2g52k4q5smn5tctmj3hl2kj7l2ql" +} + +func Address() std.Address { + return main +} + +func Backup() std.Address { + return backup +} + +func SetAddress(a std.Address) error { + if !a.IsValid() { + return ErrInvalidAddr + } + + if err := checkAuthorized(); err != nil { + return err + } + + main = a + return nil +} + +func SetBackup(a std.Address) error { + if !a.IsValid() { + return ErrInvalidAddr + } + + if err := checkAuthorized(); err != nil { + return err + } + + backup = a + return nil +} + +func checkAuthorized() error { + caller := std.PrevRealm().Addr() + isAuthorized := caller == main || caller == backup + + if !isAuthorized { + return ErrUnauthorized + } + + return nil +} diff --git a/examples/gno.land/r/nemanya/config/gno.mod b/examples/gno.land/r/nemanya/config/gno.mod new file mode 100644 index 00000000000..4388b5bd525 --- /dev/null +++ b/examples/gno.land/r/nemanya/config/gno.mod @@ -0,0 +1 @@ +module gno.land/r/nemanya/config diff --git a/examples/gno.land/r/nemanya/home/gno.mod b/examples/gno.land/r/nemanya/home/gno.mod new file mode 100644 index 00000000000..d0220197489 --- /dev/null +++ b/examples/gno.land/r/nemanya/home/gno.mod @@ -0,0 +1 @@ +module gno.land/r/nemanya/home diff --git a/examples/gno.land/r/nemanya/home/home.gno b/examples/gno.land/r/nemanya/home/home.gno new file mode 100644 index 00000000000..08e24baecfd --- /dev/null +++ b/examples/gno.land/r/nemanya/home/home.gno @@ -0,0 +1,280 @@ +package home + +import ( + "std" + "strings" + + "gno.land/p/demo/ufmt" + "gno.land/r/nemanya/config" +) + +type SocialLink struct { + URL string + Text string +} + +type Sponsor struct { + Address std.Address + Amount std.Coins +} + +type Project struct { + Name string + Description string + URL string + ImageURL string + Sponsors map[std.Address]Sponsor +} + +var ( + textArt string + aboutMe string + sponsorInfo string + socialLinks map[string]SocialLink + gnoProjects map[string]Project + otherProjects map[string]Project + totalDonations std.Coins +) + +func init() { + textArt = renderTextArt() + aboutMe = "I am a student of IT at Faculty of Sciences in Novi Sad, Serbia. My background is mainly in web and low-level programming, but since Web3 Bootcamp at Petnica this year I've been actively learning about blockchain and adjacent technologies. I am excited about contributing to the gno.land ecosystem and learning from the community.\n\n" + sponsorInfo = "You can sponsor a project by sending GNOT to this address. Your sponsorship will be displayed on the project page. Thank you for supporting the development of gno.land!\n\n" + + socialLinks = map[string]SocialLink{ + "GitHub": {URL: "https://github.com/Nemanya8", Text: "Explore my repositories and open-source contributions."}, + "LinkedIn": {URL: "https://www.linkedin.com/in/nemanjamatic/", Text: "Connect with me professionally."}, + "Email Me": {URL: "mailto:matic.nemanya@gmail.com", Text: "Reach out for collaboration or inquiries."}, + } + + gnoProjects = make(map[string]Project) + otherProjects = make(map[string]Project) + + gnoProjects["Liberty Bridge"] = Project{ + Name: "Liberty Bridge", + Description: "Liberty Bridge was my first Web3 project, developed as part of the Web3 Bootcamp at Petnica. This project served as a centralized bridge between Ethereum and gno.land, enabling seamless asset transfers and fostering interoperability between the two ecosystems.\n\n The primary objective of Liberty Bridge was to address the challenges of connecting decentralized networks by implementing a user-friendly solution that simplified the process for users. The project incorporated mechanisms to securely transfer assets between the Ethereum and gno.land blockchains, ensuring efficiency and reliability while maintaining a centralized framework for governance and operations.\n\n Through this project, I gained hands-on knowledge of blockchain interoperability, Web3 protocols, and the intricacies of building solutions that bridge different blockchain ecosystems.\n\n", + URL: "https://gno.land", + ImageURL: "https://github.com/Milosevic02/LibertyBridge/raw/main/lb_banner.png", + Sponsors: make(map[std.Address]Sponsor), + } + + otherProjects["Incognito"] = Project{ + Name: "Incognito", + Description: "Incognito is a Web3 platform built for Ethereum-based chains, designed to connect advertisers with users in a privacy-first and mutually beneficial way. Its modular architecture makes it easily expandable to other blockchains. Developed during the ETH Sofia Hackathon, it was recognized as a winning project for its innovation and impact.\n\n The platform allows advertisers to send personalized ads while sharing a portion of the marketing budget with users. It uses machine learning to match users based on wallet activity, ensuring precise targeting. User emails are stored securely on-chain and never shared, prioritizing privacy and transparency.\n\n With all campaign data stored on-chain, Incognito ensures decentralization and accountability. By rewarding users and empowering advertisers, it sets a new standard for fair and transparent blockchain-based advertising.", + URL: "https://github.com/Milosevic02/Incognito-ETHSofia", + ImageURL: "", + Sponsors: make(map[std.Address]Sponsor), + } +} + +func Render(path string) string { + var sb strings.Builder + sb.WriteString("# Hi, I'm\n") + sb.WriteString(textArt) + sb.WriteString("---\n") + sb.WriteString("## About me\n") + sb.WriteString(aboutMe) + sb.WriteString(sponsorInfo) + sb.WriteString(ufmt.Sprintf("# Total Sponsor Donations: %s\n", totalDonations.String())) + sb.WriteString("---\n") + sb.WriteString(renderProjects(gnoProjects, "Gno Projects")) + sb.WriteString("---\n") + sb.WriteString(renderProjects(otherProjects, "Other Projects")) + sb.WriteString("---\n") + sb.WriteString(renderSocialLinks()) + + return sb.String() +} + +func renderTextArt() string { + var sb strings.Builder + sb.WriteString("```\n") + sb.WriteString(" ___ ___ ___ ___ ___ ___ ___ \n") + sb.WriteString(" /\\__\\ /\\ \\ /\\__\\ /\\ \\ /\\__\\ |\\__\\ /\\ \\ \n") + sb.WriteString(" /::| | /::\\ \\ /::| | /::\\ \\ /::| | |:| | /::\\ \\ \n") + sb.WriteString(" /:|:| | /:/\\:\\ \\ /:|:| | /:/\\:\\ \\ /:|:| | |:| | /:/\\:\\ \\ \n") + sb.WriteString(" /:/|:| |__ /::\\~\\:\\ \\ /:/|:|__|__ /::\\~\\:\\ \\ /:/|:| |__ |:|__|__ /::\\~\\:\\ \\ \n") + sb.WriteString(" /:/ |:| /\\__\\ /:/\\:\\ \\:\\__\\ /:/ |::::\\__\\ /:/\\:\\ \\:\\__\\ /:/ |:| /\\__\\ /::::\\__\\ /:/\\:\\ \\:\\__\\\n") + sb.WriteString(" \\/__|:|/:/ / \\:\\~\\:\\ \\/__/ \\/__/~~/:/ / \\/__\\:\\/:/ / \\/__|:|/:/ / /:/~~/~ \\/__\\:\\/:/ / \n") + sb.WriteString(" |:/:/ / \\:\\ \\:\\__\\ /:/ / \\::/ / |:/:/ / /:/ / \\::/ / \n") + sb.WriteString(" |::/ / \\:\\ \\/__/ /:/ / /:/ / |::/ / \\/__/ /:/ / \n") + sb.WriteString(" /:/ / \\:\\__\\ /:/ / /:/ / /:/ / /:/ / \n") + sb.WriteString(" \\/__/ \\/__/ \\/__/ \\/__/ \\/__/ \\/__/ \n") + sb.WriteString("\n```\n") + return sb.String() +} + +func renderSocialLinks() string { + var sb strings.Builder + sb.WriteString("## Links\n\n") + sb.WriteString("You can find me here:\n\n") + sb.WriteString(ufmt.Sprintf("- [GitHub](%s) - %s\n", socialLinks["GitHub"].URL, socialLinks["GitHub"].Text)) + sb.WriteString(ufmt.Sprintf("- [LinkedIn](%s) - %s\n", socialLinks["LinkedIn"].URL, socialLinks["LinkedIn"].Text)) + sb.WriteString(ufmt.Sprintf("- [Email Me](%s) - %s\n", socialLinks["Email Me"].URL, socialLinks["Email Me"].Text)) + sb.WriteString("\n") + return sb.String() +} + +func renderProjects(projectsMap map[string]Project, title string) string { + var sb strings.Builder + sb.WriteString(ufmt.Sprintf("## %s\n\n", title)) + for _, project := range projectsMap { + if project.ImageURL != "" { + sb.WriteString(ufmt.Sprintf("![%s](%s)\n\n", project.Name, project.ImageURL)) + } + sb.WriteString(ufmt.Sprintf("### [%s](%s)\n\n", project.Name, project.URL)) + sb.WriteString(project.Description + "\n\n") + + if len(project.Sponsors) > 0 { + sb.WriteString(ufmt.Sprintf("#### %s Sponsors\n", project.Name)) + for _, sponsor := range project.Sponsors { + sb.WriteString(ufmt.Sprintf("- %s: %s\n", sponsor.Address.String(), sponsor.Amount.String())) + } + sb.WriteString("\n") + } + } + return sb.String() +} + +func UpdateLink(name, newURL string) { + if !isAuthorized(std.PrevRealm().Addr()) { + panic(config.ErrUnauthorized) + } + + if _, exists := socialLinks[name]; !exists { + panic("Link with the given name does not exist") + } + + socialLinks[name] = SocialLink{ + URL: newURL, + Text: socialLinks[name].Text, + } +} + +func UpdateAboutMe(text string) { + if !isAuthorized(std.PrevRealm().Addr()) { + panic(config.ErrUnauthorized) + } + + aboutMe = text +} + +func AddGnoProject(name, description, url, imageURL string) { + if !isAuthorized(std.PrevRealm().Addr()) { + panic(config.ErrUnauthorized) + } + project := Project{ + Name: name, + Description: description, + URL: url, + ImageURL: imageURL, + Sponsors: make(map[std.Address]Sponsor), + } + gnoProjects[name] = project +} + +func DeleteGnoProject(projectName string) { + if !isAuthorized(std.PrevRealm().Addr()) { + panic(config.ErrUnauthorized) + } + + if _, exists := gnoProjects[projectName]; !exists { + panic("Project not found") + } + + delete(gnoProjects, projectName) +} + +func AddOtherProject(name, description, url, imageURL string) { + if !isAuthorized(std.PrevRealm().Addr()) { + panic(config.ErrUnauthorized) + } + project := Project{ + Name: name, + Description: description, + URL: url, + ImageURL: imageURL, + Sponsors: make(map[std.Address]Sponsor), + } + otherProjects[name] = project +} + +func RemoveOtherProject(projectName string) { + if !isAuthorized(std.PrevRealm().Addr()) { + panic(config.ErrUnauthorized) + } + + if _, exists := otherProjects[projectName]; !exists { + panic("Project not found") + } + + delete(otherProjects, projectName) +} + +func isAuthorized(addr std.Address) bool { + return addr == config.Address() || addr == config.Backup() +} + +func SponsorGnoProject(projectName string) { + address := std.GetOrigCaller() + amount := std.GetOrigSend() + + if amount.AmountOf("ugnot") == 0 { + panic("Donation must include GNOT") + } + + project, exists := gnoProjects[projectName] + if !exists { + panic("Gno project not found") + } + + project.Sponsors[address] = Sponsor{ + Address: address, + Amount: project.Sponsors[address].Amount.Add(amount), + } + + totalDonations = totalDonations.Add(amount) + + gnoProjects[projectName] = project +} + +func SponsorOtherProject(projectName string) { + address := std.GetOrigCaller() + amount := std.GetOrigSend() + + if amount.AmountOf("ugnot") == 0 { + panic("Donation must include GNOT") + } + + project, exists := otherProjects[projectName] + if !exists { + panic("Other project not found") + } + + project.Sponsors[address] = Sponsor{ + Address: address, + Amount: project.Sponsors[address].Amount.Add(amount), + } + + totalDonations = totalDonations.Add(amount) + + otherProjects[projectName] = project +} + +func Withdraw() string { + if !isAuthorized(std.PrevRealm().Addr()) { + panic(config.ErrUnauthorized) + } + + banker := std.GetBanker(std.BankerTypeRealmSend) + realmAddress := std.GetOrigPkgAddr() + coins := banker.GetCoins(realmAddress) + + if len(coins) == 0 { + return "No coins available to withdraw" + } + + banker.SendCoins(realmAddress, config.Address(), coins) + + return "Successfully withdrew all coins to config address" +} diff --git a/examples/gno.land/r/stefann/home/home.gno b/examples/gno.land/r/stefann/home/home.gno index 9586f377311..f54721ce37c 100644 --- a/examples/gno.land/r/stefann/home/home.gno +++ b/examples/gno.land/r/stefann/home/home.gno @@ -8,6 +8,8 @@ import ( "gno.land/p/demo/avl" "gno.land/p/demo/ownable" "gno.land/p/demo/ufmt" + "gno.land/r/demo/users" + "gno.land/r/leon/hof" "gno.land/r/stefann/registry" ) @@ -23,7 +25,6 @@ type Sponsor struct { } type Profile struct { - pfp string aboutMe []string } @@ -49,15 +50,15 @@ var ( func init() { owner = ownable.NewWithAddress(registry.MainAddr()) + hof.Register() profile = Profile{ - pfp: "https://i.ibb.co/Bc5YNCx/DSC-0095a.jpg", aboutMe: []string{ - `### About Me`, - `Hey there! I’m Stefan, a student of Computer Science. I’m all about exploring and adventure — whether it’s diving into the latest tech or discovering a new city, I’m always up for the challenge!`, + `## About Me`, + `### Hey there! I’m Stefan, a student of Computer Science. I’m all about exploring and adventure — whether it’s diving into the latest tech or discovering a new city, I’m always up for the challenge!`, - `### Contributions`, - `I'm just getting started, but you can follow my journey through gno.land right [here](https://github.com/gnolang/hackerspace/issues/94) 🔗`, + `## Contributions`, + `### I'm just getting started, but you can follow my journey through gno.land right [here](https://github.com/gnolang/hackerspace/issues/94) 🔗`, }, } @@ -83,7 +84,7 @@ func init() { } sponsorship = Sponsorship{ - maxSponsors: 5, + maxSponsors: 3, sponsors: avl.NewTree(), DonationsCount: 0, sponsorsCount: 0, @@ -106,11 +107,6 @@ func UpdateJarLink(newLink string) { travel.jarLink = newLink } -func UpdatePFP(url string) { - owner.AssertCallerIsOwner() - profile.pfp = url -} - func UpdateAboutMe(aboutMeStr string) { owner.AssertCallerIsOwner() profile.aboutMe = strings.Split(aboutMeStr, "|") @@ -203,46 +199,27 @@ func Render(path string) string { } func renderAboutMe() string { - out := "
" - - out += "
\n\n" + out := "" - out += ufmt.Sprintf("
\n\n", travel.cities[travel.currentCityIndex%len(travel.cities)].URL) - - out += ufmt.Sprintf("my profile pic\n\n", profile.pfp) - - out += "
\n\n" + out += ufmt.Sprintf("![Current Location](%s)\n\n", travel.cities[travel.currentCityIndex%len(travel.cities)].URL) for _, rows := range profile.aboutMe { - out += "
\n\n" out += rows + "\n\n" - out += "
\n\n" } - out += "
\n\n" - return out } func renderTips() string { - out := `
` + "\n\n" + out := "# Help Me Travel The World\n\n" - out += `
` + "\n" + out += ufmt.Sprintf("## I am currently in %s, tip the jar to send me somewhere else!\n\n", travel.cities[travel.currentCityIndex].Name) + out += "### **Click** the jar, **tip** in GNOT coins, and **watch** my background change as I head to a new adventure!\n\n" - out += `

Help Me Travel The World

` + "\n\n" - - out += renderTipsJar() + "\n" - - out += ufmt.Sprintf(`I am currently in %s,
tip the jar to send me somewhere else!
`, travel.cities[travel.currentCityIndex].Name) - - out += `
Click the jar, tip in GNOT coins, and watch my background change as I head to a new adventure!

` + "\n\n" + out += renderTipsJar() + "\n\n" out += renderSponsors() - out += `
` + "\n\n" - - out += `
` + "\n" - return out } @@ -253,11 +230,27 @@ func formatAddress(address string) string { return address[:4] + "..." + address[len(address)-4:] } +func getDisplayName(addr std.Address) string { + if user := users.GetUserByAddress(addr); user != nil { + return user.Name + } + return formatAddress(addr.String()) +} + +func formatAmount(amount std.Coins) string { + ugnot := amount.AmountOf("ugnot") + if ugnot >= 1000000 { + gnot := float64(ugnot) / 1000000 + return ufmt.Sprintf("`%v`*GNOT*", gnot) + } + return ufmt.Sprintf("`%d`*ugnot*", ugnot) +} + func renderSponsors() string { - out := `

Sponsor Leaderboard

` + "\n" + out := "## Sponsor Leaderboard\n\n" if sponsorship.sponsorsCount == 0 { - return out + `

No sponsors yet. Be the first to tip the jar!

` + "\n" + return out + "No sponsors yet. Be the first to tip the jar!\n" } topSponsors := GetTopSponsors() @@ -266,38 +259,30 @@ func renderSponsors() string { numSponsors = sponsorship.maxSponsors } - out += `
    ` + "\n" - for i := 0; i < numSponsors; i++ { sponsor := topSponsors[i] - isLastItem := (i == numSponsors-1) - - padding := "10px 5px" - border := "border-bottom: 1px solid #ddd;" - - if isLastItem { - padding = "8px 5px" - border = "" + position := "" + switch i { + case 0: + position = "đŸĨ‡" + case 1: + position = "đŸĨˆ" + case 2: + position = "đŸĨ‰" + default: + position = ufmt.Sprintf("%d.", i+1) } - out += ufmt.Sprintf( - `
  • - %d. %s - %s -
  • `, - padding, border, i+1, formatAddress(sponsor.Address.String()), sponsor.Amount.String(), + out += ufmt.Sprintf("%s **%s** - %s\n\n", + position, + getDisplayName(sponsor.Address), + formatAmount(sponsor.Amount), ) } - return out + return out + "\n" } func renderTipsJar() string { - out := ufmt.Sprintf(``, travel.jarLink) + "\n" - - out += `Tips Jar` + "\n" - - out += `` + "\n" - - return out + return ufmt.Sprintf("[![Tips Jar](https://i.ibb.co/4TH9zbw/tips-jar.png)](%s)", travel.jarLink) } diff --git a/examples/gno.land/r/stefann/home/home_test.gno b/examples/gno.land/r/stefann/home/home_test.gno index ca146b9eb13..b8ea88670a6 100644 --- a/examples/gno.land/r/stefann/home/home_test.gno +++ b/examples/gno.land/r/stefann/home/home_test.gno @@ -9,19 +9,6 @@ import ( "gno.land/p/demo/testutils" ) -func TestUpdatePFP(t *testing.T) { - var owner = std.Address("g1sd5ezmxt4rwpy52u6wl3l3y085n8x0p6nllxm8") - std.TestSetOrigCaller(owner) - - profile.pfp = "" - - UpdatePFP("https://example.com/pic.png") - - if profile.pfp != "https://example.com/pic.png" { - t.Fatalf("expected pfp to be https://example.com/pic.png, got %s", profile.pfp) - } -} - func TestUpdateAboutMe(t *testing.T) { var owner = std.Address("g1sd5ezmxt4rwpy52u6wl3l3y085n8x0p6nllxm8") std.TestSetOrigCaller(owner) diff --git a/examples/gno.land/r/sys/users/verify.gno b/examples/gno.land/r/sys/users/verify.gno index a836e84683d..71869fda1a1 100644 --- a/examples/gno.land/r/sys/users/verify.gno +++ b/examples/gno.land/r/sys/users/verify.gno @@ -48,8 +48,8 @@ func VerifyNameByUser(enable bool, address std.Address, name string) bool { // Enable this package. func AdminEnable() { - if err := owner.CallerIsOwner(); err != nil { - panic(err) + if !owner.CallerIsOwner() { + panic(ownable.ErrUnauthorized) } enabled = true @@ -57,8 +57,8 @@ func AdminEnable() { // Disable this package. func AdminDisable() { - if err := owner.CallerIsOwner(); err != nil { - panic(err) + if !owner.CallerIsOwner() { + panic(ownable.ErrUnauthorized) } enabled = false @@ -66,8 +66,8 @@ func AdminDisable() { // AdminUpdateVerifyCall updates the method that verifies the namespace. func AdminUpdateVerifyCall(check VerifyNameFunc) { - if err := owner.CallerIsOwner(); err != nil { - panic(err) + if !owner.CallerIsOwner() { + panic(ownable.ErrUnauthorized) } checkFunc = check @@ -75,8 +75,8 @@ func AdminUpdateVerifyCall(check VerifyNameFunc) { // AdminTransferOwnership transfers the ownership to a new owner. func AdminTransferOwnership(newOwner std.Address) error { - if err := owner.CallerIsOwner(); err != nil { - panic(err) + if !owner.CallerIsOwner() { + panic(ownable.ErrUnauthorized) } return owner.TransferOwnership(newOwner) diff --git a/examples/gno.land/r/ursulovic/home/gno.mod b/examples/gno.land/r/ursulovic/home/gno.mod new file mode 100644 index 00000000000..78163ab2bb5 --- /dev/null +++ b/examples/gno.land/r/ursulovic/home/gno.mod @@ -0,0 +1 @@ +module gno.land/r/ursulovic/home diff --git a/examples/gno.land/r/ursulovic/home/home.gno b/examples/gno.land/r/ursulovic/home/home.gno new file mode 100644 index 00000000000..c03d8a66868 --- /dev/null +++ b/examples/gno.land/r/ursulovic/home/home.gno @@ -0,0 +1,159 @@ +package home + +import ( + "std" + "strconv" + "strings" + + "gno.land/p/demo/ownable" + "gno.land/p/moul/md" + "gno.land/r/leon/hof" + + "gno.land/r/ursulovic/registry" +) + +var ( + aboutMe string + selectedImage string + Ownable *ownable.Ownable + + githubUrl string + linkedinUrl string + connectUrl string + imageUpdatePrice int64 + + isValidUrl func(string) bool +) + +func init() { + Ownable = ownable.NewWithAddress(registry.MainAddress()) + + aboutMe = "Hi, I'm Ivan Ursulovic, a computer engineering graduate, blockchain enthusiast, and backend developer specializing in ASP.NET. I love learning new things and taking on challenges." + selectedImage = "https://i.ibb.co/W28NPkw/beograd.webp" + + githubUrl = "https://github.com/ursulovic" + linkedinUrl = "https://www.linkedin.com/in/ivan-ursulovic-953310190/" + imageUpdatePrice = 5000000 + isValidUrl = defaultURLValidation + hof.Register() +} + +func Render(s string) string { + var sb strings.Builder + sb.WriteString(renderAboutMe()) + sb.WriteString(renderSelectedImage()) + sb.WriteString(renderContactsUrl()) + return sb.String() +} + +func defaultURLValidation(url string) bool { + const urlPrefix string = "https://i.ibb.co/" + + if !strings.HasPrefix(url, urlPrefix) { + return false + } + + if !(strings.HasSuffix(url, ".jpg") || + strings.HasSuffix(url, ".png") || + strings.HasSuffix(url, ".gif") || + strings.HasSuffix(url, ".webp")) { + return false + } + + urlPath := strings.TrimPrefix(url, "https://i.ibb.co/") + parts := strings.Split(urlPath, "/") + + if len(parts) != 2 || len(parts[0]) == 0 || len(parts[1]) == 0 { + return false + } + + return true +} + +func UpdateSelectedImage(url string) { + if !isValidUrl(url) { + panic("Url is not valid!") + } + + sentCoins := std.GetOrigSend() + + if len(sentCoins) != 1 && sentCoins.AmountOf("ugnot") == imageUpdatePrice { + panic("Please send exactly " + strconv.Itoa(int(imageUpdatePrice)) + " ugnot") + } + + selectedImage = url +} + +func renderSelectedImage() string { + var sb strings.Builder + + sb.WriteString(md.HorizontalRule()) + sb.WriteString("\n") + + sb.WriteString(md.H2("📸 Featured Image")) + sb.WriteString("\n") + + sb.WriteString(md.Image("", selectedImage)) + sb.WriteString("\n") + + sb.WriteString(md.H4("✨ " + md.Link("Change this image for "+strconv.Itoa(int(imageUpdatePrice/1000000))+" GNOT. To update, set a direct image URL from ImgBB.", "https://gno.studio/connect/view/gno.land/r/ursulovic/home?network=portal-loop") + " ✨")) + + return sb.String() +} + +func renderAboutMe() string { + var sb strings.Builder + + sb.WriteString(md.H1("👋 Welcome to Ivan's Homepage!")) + sb.WriteString("\n") + + sb.WriteString(md.H2("👨‍đŸ’ģ About Me")) + sb.WriteString("\n") + + sb.WriteString(md.Blockquote(aboutMe)) + + return sb.String() +} + +func renderContactsUrl() string { + var sb strings.Builder + + sb.WriteString(md.HorizontalRule()) + sb.WriteString("\n") + + sb.WriteString(md.H2("🔗 Let's Connect")) + sb.WriteString("\n") + + items := []string{ + "🐙 " + md.Link("GitHub", githubUrl), + "đŸ’ŧ " + md.Link("LinkedIn", linkedinUrl), + } + sb.WriteString(md.BulletList(items)) + + return sb.String() +} + +func UpdateGithubUrl(url string) { + Ownable.AssertCallerIsOwner() + githubUrl = url +} + +func UpdateLinkedinUrl(url string) { + Ownable.AssertCallerIsOwner() + linkedinUrl = url +} + +func UpdateAboutMe(text string) { + Ownable.AssertCallerIsOwner() + aboutMe = text +} + +func UpdateImagePrice(newPrice int64) { + Ownable.AssertCallerIsOwner() + imageUpdatePrice = newPrice +} + +func UpdateIsValidUrlFunction(f func(string) bool) { + Ownable.AssertCallerIsOwner() + isValidUrl = f +} diff --git a/examples/gno.land/r/ursulovic/home/home_test.gno b/examples/gno.land/r/ursulovic/home/home_test.gno new file mode 100644 index 00000000000..ff3f763d62a --- /dev/null +++ b/examples/gno.land/r/ursulovic/home/home_test.gno @@ -0,0 +1,97 @@ +package home + +import ( + "std" + "testing" + + "gno.land/p/demo/testutils" +) + +func TestUpdateGithubUrl(t *testing.T) { + caller := std.Address("g1d24j8fwnc0w5q427fauyey4gdd30qgu69k6n0x") + std.TestSetOrigCaller(caller) + + newUrl := "https://github.com/example" + + UpdateGithubUrl(newUrl) + + if githubUrl != newUrl { + t.Fatalf("GitHub url not updated properly!") + } +} + +func TestUpdateLinkedinUrl(t *testing.T) { + caller := std.Address("g1d24j8fwnc0w5q427fauyey4gdd30qgu69k6n0x") + std.TestSetOrigCaller(caller) + + newUrl := "https://www.linkedin.com/in/example" + + UpdateGithubUrl(newUrl) + + if githubUrl != newUrl { + t.Fatalf("LinkedIn url not updated properly!") + } +} + +func TestUpdateAboutMe(t *testing.T) { + caller := std.Address("g1d24j8fwnc0w5q427fauyey4gdd30qgu69k6n0x") + std.TestSetOrigCaller(caller) + + newAboutMe := "This is new description!" + + UpdateAboutMe(newAboutMe) + + if aboutMe != newAboutMe { + t.Fatalf("About mew not updated properly!") + } +} + +func TestUpdateSelectedImage(t *testing.T) { + var user = testutils.TestAddress("user") + std.TestSetOrigCaller(user) + + validImageUrl := "https://i.ibb.co/hLtmnX0/beautiful-rain-forest-ang-ka-nature-trail-doi-inthanon-national-park-thailand-36703721.webp" + + coinsSent := std.NewCoins(std.NewCoin("ugnot", 5000000)) // Update to match the price expected by your function + std.TestSetOrigSend(coinsSent, std.NewCoins()) + + UpdateSelectedImage(validImageUrl) + + if selectedImage != validImageUrl { + t.Fatalf("Valid image URL rejected!") + } + + invalidImageUrl := "https://ibb.co/Kb3rQNn" + + defer func() { + if r := recover(); r == nil { + t.Fatalf("Expected panic for invalid image URL, but got no panic") + } + }() + + UpdateSelectedImage(invalidImageUrl) + + invalidCoins := std.NewCoins(std.NewCoin("ugnot", 1000000)) + std.TestSetOrigSend(invalidCoins, std.NewCoins()) + + defer func() { + if r := recover(); r == nil { + t.Fatalf("Expected panic for incorrect coin denomination or amount, but got no panic") + } + }() + + UpdateSelectedImage(validImageUrl) +} + +func TestUpdateImagePrice(t *testing.T) { + caller := std.Address("g1d24j8fwnc0w5q427fauyey4gdd30qgu69k6n0x") + std.TestSetOrigCaller(caller) + + var newImageUpdatePrice int64 = 3000000 + + UpdateImagePrice(newImageUpdatePrice) + + if imageUpdatePrice != newImageUpdatePrice { + t.Fatalf("Image update price not updated properly!") + } +} diff --git a/examples/gno.land/r/ursulovic/registry/gno.mod b/examples/gno.land/r/ursulovic/registry/gno.mod new file mode 100644 index 00000000000..ee1f5d38780 --- /dev/null +++ b/examples/gno.land/r/ursulovic/registry/gno.mod @@ -0,0 +1 @@ +module gno.land/r/ursulovic/registry diff --git a/examples/gno.land/r/ursulovic/registry/registry.gno b/examples/gno.land/r/ursulovic/registry/registry.gno new file mode 100644 index 00000000000..0bbd6c80df5 --- /dev/null +++ b/examples/gno.land/r/ursulovic/registry/registry.gno @@ -0,0 +1,59 @@ +package registry + +import ( + "errors" + "std" +) + +var ( + mainAddress std.Address + backupAddress std.Address + + ErrInvalidAddr = errors.New("Ivan's registry: Invalid address") + ErrUnauthorized = errors.New("Ivan's registry: Unauthorized") +) + +func init() { + mainAddress = "g1d24j8fwnc0w5q427fauyey4gdd30qgu69k6n0x" + backupAddress = "g1mw2xft3eava9kfhqw3fjj3kkf3pkammty0mtv7" +} + +func MainAddress() std.Address { + return mainAddress +} + +func BackupAddress() std.Address { + return backupAddress +} + +func SetMainAddress(addr std.Address) error { + assertAuthorized() + + if !addr.IsValid() { + return ErrInvalidAddr + } + + mainAddress = addr + return nil +} + +func SetBackupAddress(addr std.Address) error { + assertAuthorized() + + if !addr.IsValid() { + return ErrInvalidAddr + } + + backupAddress = addr + return nil +} + +// It will stay here for now, might be useful later +func assertAuthorized() { + caller := std.PrevRealm().Addr() + isAuthorized := caller == mainAddress || caller == backupAddress + + if !isAuthorized { + panic(ErrUnauthorized) + } +} diff --git a/examples/gno.land/r/x/benchmark/storage/boards.gno b/examples/gno.land/r/x/benchmark/storage/boards.gno new file mode 100644 index 00000000000..adb3d2d709c --- /dev/null +++ b/examples/gno.land/r/x/benchmark/storage/boards.gno @@ -0,0 +1,97 @@ +package storage + +import ( + "strconv" + + "gno.land/p/demo/avl" +) + +var boards avl.Tree + +type Board interface { + AddPost(title, content string) + GetPost(id int) (Post, bool) + Size() int +} + +// posts are persisted in an avl tree +type TreeBoard struct { + id int + posts *avl.Tree +} + +func (b *TreeBoard) AddPost(title, content string) { + n := b.posts.Size() + p := Post{n, title, content} + b.posts.Set(strconv.Itoa(n), p) +} + +func (b *TreeBoard) GetPost(id int) (Post, bool) { + p, ok := b.posts.Get(strconv.Itoa(id)) + if ok { + return p.(Post), ok + } else { + return Post{}, ok + } +} + +func (b *TreeBoard) Size() int { + return b.posts.Size() +} + +// posts are persisted in a map +type MapBoard struct { + id int + posts map[int]Post +} + +func (b *MapBoard) AddPost(title, content string) { + n := len(b.posts) + p := Post{n, title, content} + b.posts[n] = p +} + +func (b *MapBoard) GetPost(id int) (Post, bool) { + p, ok := b.posts[id] + if ok { + return p, ok + } else { + return Post{}, ok + } +} + +func (b *MapBoard) Size() int { + return len(b.posts) +} + +// posts are persisted in a slice +type SliceBoard struct { + id int + posts []Post +} + +func (b *SliceBoard) AddPost(title, content string) { + n := len(b.posts) + p := Post{n, title, content} + b.posts = append(b.posts, p) +} + +func (b *SliceBoard) GetPost(id int) (Post, bool) { + if id < len(b.posts) { + p := b.posts[id] + + return p, true + } else { + return Post{}, false + } +} + +func (b *SliceBoard) Size() int { + return len(b.posts) +} + +type Post struct { + id int + title string + content string +} diff --git a/examples/gno.land/r/x/benchmark/storage/forum.gno b/examples/gno.land/r/x/benchmark/storage/forum.gno new file mode 100644 index 00000000000..8f1b3734de6 --- /dev/null +++ b/examples/gno.land/r/x/benchmark/storage/forum.gno @@ -0,0 +1,64 @@ +package storage + +import ( + "strconv" + + "gno.land/p/demo/avl" +) + +func init() { + // we write to three common data structure for persistence + // avl.Tree, map and slice. + posts0 := avl.NewTree() + b0 := &TreeBoard{0, posts0} + boards.Set(strconv.Itoa(0), b0) + + posts1 := make(map[int]Post) + b1 := &MapBoard{1, posts1} + boards.Set(strconv.Itoa(1), b1) + + posts2 := []Post{} + b2 := &SliceBoard{2, posts2} + boards.Set(strconv.Itoa(2), b2) +} + +// post to all boards. +func AddPost(title, content string) { + for i := 0; i < boards.Size(); i++ { + boardId := strconv.Itoa(i) + b, ok := boards.Get(boardId) + if ok { + b.(Board).AddPost(title, content) + } + } +} + +func GetPost(boardId, postId int) string { + b, ok := boards.Get(strconv.Itoa(boardId)) + var res string + + if ok { + p, ok := b.(Board).GetPost(postId) + if ok { + res = p.title + "," + p.content + } + } + return res +} + +func GetPostSize(boardId int) int { + b, ok := boards.Get(strconv.Itoa(boardId)) + var res int + + if ok { + res = b.(Board).Size() + } else { + res = -1 + } + + return res +} + +func GetBoardSize() int { + return boards.Size() +} diff --git a/examples/gno.land/r/x/benchmark/storage/gno.mod b/examples/gno.land/r/x/benchmark/storage/gno.mod new file mode 100644 index 00000000000..04bea3012f3 --- /dev/null +++ b/examples/gno.land/r/x/benchmark/storage/gno.mod @@ -0,0 +1 @@ +module gno.land/r/x/benchmark/storage diff --git a/examples/gno.land/r/x/jeronimo_render_proxy/example.gno b/examples/gno.land/r/x/jeronimo_render_proxy/example.gno new file mode 100644 index 00000000000..7b3da098232 --- /dev/null +++ b/examples/gno.land/r/x/jeronimo_render_proxy/example.gno @@ -0,0 +1,20 @@ +package example + +func Render(string) string { + return `# Render Proxy + +This example shows how proxying render calls can be used to allow updating realms to new +versions while keeping the same realm path. The idea is to have a simple "parent" realm +that only keeps track of the latest realm version and forwards all render calls to it. + +By only focusing on the 'Render()' function the proxy realm keeps its public functions +stable allowing each version to update their public functions and exposed types without +needing to also update the proxy realm. + +Any interaction or transaction must be sent to the latest, or target, realm version while +render calls are sent to the proxy realm. + +Each realm version registers itself on deployment as the latest available version, and +its allowed to do so because each versioned realm path shares the proxy realm path. +` +} diff --git a/examples/gno.land/r/x/jeronimo_render_proxy/gno.mod b/examples/gno.land/r/x/jeronimo_render_proxy/gno.mod new file mode 100644 index 00000000000..9236b28f5ad --- /dev/null +++ b/examples/gno.land/r/x/jeronimo_render_proxy/gno.mod @@ -0,0 +1 @@ +module gno.land/r/x/jeronimo_render_proxy diff --git a/examples/gno.land/r/x/jeronimo_render_proxy/home/home.gno b/examples/gno.land/r/x/jeronimo_render_proxy/home/home.gno new file mode 100644 index 00000000000..c73e99cc583 --- /dev/null +++ b/examples/gno.land/r/x/jeronimo_render_proxy/home/home.gno @@ -0,0 +1,52 @@ +package home + +import ( + "std" + "strings" +) + +// RenderFn defines the type for the render function of realms. +type RenderFn func(string) string + +var current = struct { + realmPath string + renderFn RenderFn +}{} + +// CurrentRealmPath returns the path of the realm that is currently registered. +func CurrentRealmPath() string { + return current.realmPath +} + +// Register registers a render function of a realm. +func Register(fn RenderFn) { + if fn == nil { + panic("render function must not be nil") + } + + proxyPath := std.CurrentRealm().PkgPath() + callerPath := std.PrevRealm().PkgPath() + if !strings.HasPrefix(callerPath, proxyPath+"/") { + panic("caller realm path must start with " + proxyPath) + } + + current.renderFn = fn + current.realmPath = callerPath +} + +// URL returns a URL that links to the proxy realm. +func URL(renderPath string) string { + url := "http://" + std.CurrentRealm().PkgPath() + if renderPath != "" { + url += ":" + renderPath + } + return url +} + +// Render renders the rendered Markdown of the realm that is currently registered. +func Render(path string) string { + if current.renderFn == nil { + panic("no realm has been registered") + } + return current.renderFn(path) +} diff --git a/examples/gno.land/r/x/jeronimo_render_proxy/home/v1/v1.gno b/examples/gno.land/r/x/jeronimo_render_proxy/home/v1/v1.gno new file mode 100644 index 00000000000..8698998577c --- /dev/null +++ b/examples/gno.land/r/x/jeronimo_render_proxy/home/v1/v1.gno @@ -0,0 +1,16 @@ +package v1 + +import "gno.land/r/x/jeronimo_render_proxy/home" + +func init() { + // Register the private render function with the render proxy + home.Register(render) +} + +func render(string) string { + return "Rendered by v1" +} + +func Render(string) string { + return "[Home](" + home.URL("") + ")" +} diff --git a/examples/gno.land/r/x/jeronimo_render_proxy/home/v1/z_filetest.gno b/examples/gno.land/r/x/jeronimo_render_proxy/home/v1/z_filetest.gno new file mode 100644 index 00000000000..cebe2aeb5ba --- /dev/null +++ b/examples/gno.land/r/x/jeronimo_render_proxy/home/v1/z_filetest.gno @@ -0,0 +1,10 @@ +package main + +import "gno.land/r/x/jeronimo_render_proxy/home/v1" + +func main() { + println(v1.Render("")) +} + +// Output: +// [Home](http://gno.land/r/x/jeronimo_render_proxy/home) diff --git a/examples/gno.land/r/x/jeronimo_render_proxy/home/v2/v2.gno b/examples/gno.land/r/x/jeronimo_render_proxy/home/v2/v2.gno new file mode 100644 index 00000000000..031f8568441 --- /dev/null +++ b/examples/gno.land/r/x/jeronimo_render_proxy/home/v2/v2.gno @@ -0,0 +1,16 @@ +package v2 + +import "gno.land/r/x/jeronimo_render_proxy/home" + +func init() { + // Register the private render function with the render proxy + home.Register(render) +} + +func render(string) string { + return "Rendered by v2" +} + +func Render(string) string { + return "[Home](" + home.URL("") + ")" +} diff --git a/examples/gno.land/r/x/jeronimo_render_proxy/home/v2/z_filetest.gno b/examples/gno.land/r/x/jeronimo_render_proxy/home/v2/z_filetest.gno new file mode 100644 index 00000000000..feff15533ee --- /dev/null +++ b/examples/gno.land/r/x/jeronimo_render_proxy/home/v2/z_filetest.gno @@ -0,0 +1,10 @@ +package main + +import "gno.land/r/x/jeronimo_render_proxy/home/v2" + +func main() { + println(v2.Render("")) +} + +// Output: +// [Home](http://gno.land/r/x/jeronimo_render_proxy/home) diff --git a/examples/gno.land/r/x/jeronimo_render_proxy/home/z_a_filetest.gno b/examples/gno.land/r/x/jeronimo_render_proxy/home/z_a_filetest.gno new file mode 100644 index 00000000000..c7d4d7febd2 --- /dev/null +++ b/examples/gno.land/r/x/jeronimo_render_proxy/home/z_a_filetest.gno @@ -0,0 +1,10 @@ +package main + +import "gno.land/r/x/jeronimo_render_proxy/home" + +func main() { + home.Render("") +} + +// Error: +// no realm has been registered diff --git a/examples/gno.land/r/x/jeronimo_render_proxy/home/z_b_filetest.gno b/examples/gno.land/r/x/jeronimo_render_proxy/home/z_b_filetest.gno new file mode 100644 index 00000000000..6ebdace67b4 --- /dev/null +++ b/examples/gno.land/r/x/jeronimo_render_proxy/home/z_b_filetest.gno @@ -0,0 +1,15 @@ +package main + +import ( + "gno.land/r/x/jeronimo_render_proxy/home" + _ "gno.land/r/x/jeronimo_render_proxy/home/v1" +) + +func main() { + println(home.CurrentRealmPath()) + println(home.Render("")) +} + +// Output: +// gno.land/r/x/jeronimo_render_proxy/home/v1 +// Rendered by v1 diff --git a/examples/gno.land/r/x/jeronimo_render_proxy/home/z_c_filetest.gno b/examples/gno.land/r/x/jeronimo_render_proxy/home/z_c_filetest.gno new file mode 100644 index 00000000000..f85b13bc5dd --- /dev/null +++ b/examples/gno.land/r/x/jeronimo_render_proxy/home/z_c_filetest.gno @@ -0,0 +1,16 @@ +package main + +import ( + "gno.land/r/x/jeronimo_render_proxy/home" + _ "gno.land/r/x/jeronimo_render_proxy/home/v1" + _ "gno.land/r/x/jeronimo_render_proxy/home/v2" +) + +func main() { + println(home.CurrentRealmPath()) + println(home.Render("")) +} + +// Output: +// gno.land/r/x/jeronimo_render_proxy/home/v2 +// Rendered by v2 diff --git a/gno.land/Makefile b/gno.land/Makefile index 7b2afd5779f..075560f44a9 100644 --- a/gno.land/Makefile +++ b/gno.land/Makefile @@ -47,6 +47,12 @@ install.gnoland:; go install ./cmd/gnoland install.gnoweb:; go install ./cmd/gnoweb install.gnokey:; go install ./cmd/gnokey +.PHONY: dev.gnoweb generate.gnoweb +dev.gnoweb: + make -C ./pkg/gnoweb dev +generate.gnoweb: + make -C ./pkg/gnoweb generate + .PHONY: fclean fclean: clean rm -rf gnoland-data genesis.json diff --git a/gno.land/cmd/gnoland/config_get_test.go b/gno.land/cmd/gnoland/config_get_test.go index f2ddc5ca6d0..84cf0ba3d37 100644 --- a/gno.land/cmd/gnoland/config_get_test.go +++ b/gno.land/cmd/gnoland/config_get_test.go @@ -289,14 +289,6 @@ func TestConfig_Get_Base(t *testing.T) { }, true, }, - { - "filter peers flag fetched", - "filter_peers", - func(loadedCfg *config.Config, value []byte) { - assert.Equal(t, loadedCfg.FilterPeers, unmarshalJSONCommon[bool](t, value)) - }, - false, - }, } verifyGetTestTableCommon(t, testTable) @@ -616,19 +608,11 @@ func TestConfig_Get_P2P(t *testing.T) { }, true, }, - { - "upnp toggle", - "p2p.upnp", - func(loadedCfg *config.Config, value []byte) { - assert.Equal(t, loadedCfg.P2P.UPNP, unmarshalJSONCommon[bool](t, value)) - }, - false, - }, { "max inbound peers", "p2p.max_num_inbound_peers", func(loadedCfg *config.Config, value []byte) { - assert.Equal(t, loadedCfg.P2P.MaxNumInboundPeers, unmarshalJSONCommon[int](t, value)) + assert.Equal(t, loadedCfg.P2P.MaxNumInboundPeers, unmarshalJSONCommon[uint64](t, value)) }, false, }, @@ -636,7 +620,7 @@ func TestConfig_Get_P2P(t *testing.T) { "max outbound peers", "p2p.max_num_outbound_peers", func(loadedCfg *config.Config, value []byte) { - assert.Equal(t, loadedCfg.P2P.MaxNumOutboundPeers, unmarshalJSONCommon[int](t, value)) + assert.Equal(t, loadedCfg.P2P.MaxNumOutboundPeers, unmarshalJSONCommon[uint64](t, value)) }, false, }, @@ -676,15 +660,7 @@ func TestConfig_Get_P2P(t *testing.T) { "pex reactor toggle", "p2p.pex", func(loadedCfg *config.Config, value []byte) { - assert.Equal(t, loadedCfg.P2P.PexReactor, unmarshalJSONCommon[bool](t, value)) - }, - false, - }, - { - "seed mode", - "p2p.seed_mode", - func(loadedCfg *config.Config, value []byte) { - assert.Equal(t, loadedCfg.P2P.SeedMode, unmarshalJSONCommon[bool](t, value)) + assert.Equal(t, loadedCfg.P2P.PeerExchange, unmarshalJSONCommon[bool](t, value)) }, false, }, @@ -704,30 +680,6 @@ func TestConfig_Get_P2P(t *testing.T) { }, true, }, - { - "allow duplicate IP", - "p2p.allow_duplicate_ip", - func(loadedCfg *config.Config, value []byte) { - assert.Equal(t, loadedCfg.P2P.AllowDuplicateIP, unmarshalJSONCommon[bool](t, value)) - }, - false, - }, - { - "handshake timeout", - "p2p.handshake_timeout", - func(loadedCfg *config.Config, value []byte) { - assert.Equal(t, loadedCfg.P2P.HandshakeTimeout, unmarshalJSONCommon[time.Duration](t, value)) - }, - false, - }, - { - "dial timeout", - "p2p.dial_timeout", - func(loadedCfg *config.Config, value []byte) { - assert.Equal(t, loadedCfg.P2P.DialTimeout, unmarshalJSONCommon[time.Duration](t, value)) - }, - false, - }, } verifyGetTestTableCommon(t, testTable) diff --git a/gno.land/cmd/gnoland/config_set_test.go b/gno.land/cmd/gnoland/config_set_test.go index cb831f0e502..39880313043 100644 --- a/gno.land/cmd/gnoland/config_set_test.go +++ b/gno.land/cmd/gnoland/config_set_test.go @@ -244,19 +244,6 @@ func TestConfig_Set_Base(t *testing.T) { assert.Equal(t, value, loadedCfg.ProfListenAddress) }, }, - { - "filter peers flag updated", - []string{ - "filter_peers", - "true", - }, - func(loadedCfg *config.Config, value string) { - boolVal, err := strconv.ParseBool(value) - require.NoError(t, err) - - assert.Equal(t, boolVal, loadedCfg.FilterPeers) - }, - }, } verifySetTestTableCommon(t, testTable) @@ -505,19 +492,6 @@ func TestConfig_Set_P2P(t *testing.T) { assert.Equal(t, value, loadedCfg.P2P.PersistentPeers) }, }, - { - "upnp toggle updated", - []string{ - "p2p.upnp", - "false", - }, - func(loadedCfg *config.Config, value string) { - boolVal, err := strconv.ParseBool(value) - require.NoError(t, err) - - assert.Equal(t, boolVal, loadedCfg.P2P.UPNP) - }, - }, { "max inbound peers updated", []string{ @@ -588,20 +562,7 @@ func TestConfig_Set_P2P(t *testing.T) { boolVal, err := strconv.ParseBool(value) require.NoError(t, err) - assert.Equal(t, boolVal, loadedCfg.P2P.PexReactor) - }, - }, - { - "seed mode updated", - []string{ - "p2p.seed_mode", - "false", - }, - func(loadedCfg *config.Config, value string) { - boolVal, err := strconv.ParseBool(value) - require.NoError(t, err) - - assert.Equal(t, boolVal, loadedCfg.P2P.SeedMode) + assert.Equal(t, boolVal, loadedCfg.P2P.PeerExchange) }, }, { @@ -614,39 +575,6 @@ func TestConfig_Set_P2P(t *testing.T) { assert.Equal(t, value, loadedCfg.P2P.PrivatePeerIDs) }, }, - { - "allow duplicate IPs updated", - []string{ - "p2p.allow_duplicate_ip", - "false", - }, - func(loadedCfg *config.Config, value string) { - boolVal, err := strconv.ParseBool(value) - require.NoError(t, err) - - assert.Equal(t, boolVal, loadedCfg.P2P.AllowDuplicateIP) - }, - }, - { - "handshake timeout updated", - []string{ - "p2p.handshake_timeout", - "1s", - }, - func(loadedCfg *config.Config, value string) { - assert.Equal(t, value, loadedCfg.P2P.HandshakeTimeout.String()) - }, - }, - { - "dial timeout updated", - []string{ - "p2p.dial_timeout", - "1s", - }, - func(loadedCfg *config.Config, value string) { - assert.Equal(t, value, loadedCfg.P2P.DialTimeout.String()) - }, - }, } verifySetTestTableCommon(t, testTable) diff --git a/gno.land/cmd/gnoland/integration_test.go b/gno.land/cmd/gnoland/integration_test.go deleted file mode 100644 index 37451df9704..00000000000 --- a/gno.land/cmd/gnoland/integration_test.go +++ /dev/null @@ -1,11 +0,0 @@ -package main - -import ( - "testing" - - "github.com/gnolang/gno/gno.land/pkg/integration" -) - -func TestTestdata(t *testing.T) { - integration.RunGnolandTestscripts(t, "testdata") -} diff --git a/gno.land/cmd/gnoland/secrets_common.go b/gno.land/cmd/gnoland/secrets_common.go index d40e90f6b48..500336e3489 100644 --- a/gno.land/cmd/gnoland/secrets_common.go +++ b/gno.land/cmd/gnoland/secrets_common.go @@ -8,7 +8,7 @@ import ( "github.com/gnolang/gno/tm2/pkg/amino" "github.com/gnolang/gno/tm2/pkg/bft/privval" "github.com/gnolang/gno/tm2/pkg/crypto" - "github.com/gnolang/gno/tm2/pkg/p2p" + "github.com/gnolang/gno/tm2/pkg/p2p/types" ) var ( @@ -54,7 +54,7 @@ func isValidDirectory(dirPath string) bool { } type secretData interface { - privval.FilePVKey | privval.FilePVLastSignState | p2p.NodeKey + privval.FilePVKey | privval.FilePVLastSignState | types.NodeKey } // readSecretData reads the secret data from the given path @@ -145,7 +145,7 @@ func validateValidatorStateSignature( } // validateNodeKey validates the node's p2p key -func validateNodeKey(key *p2p.NodeKey) error { +func validateNodeKey(key *types.NodeKey) error { if key.PrivKey == nil { return errInvalidNodeKey } diff --git a/gno.land/cmd/gnoland/secrets_common_test.go b/gno.land/cmd/gnoland/secrets_common_test.go index 34592c3bd8f..38c4772c705 100644 --- a/gno.land/cmd/gnoland/secrets_common_test.go +++ b/gno.land/cmd/gnoland/secrets_common_test.go @@ -5,7 +5,7 @@ import ( "testing" "github.com/gnolang/gno/tm2/pkg/crypto" - "github.com/gnolang/gno/tm2/pkg/p2p" + "github.com/gnolang/gno/tm2/pkg/p2p/types" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -26,7 +26,7 @@ func TestCommon_SaveReadData(t *testing.T) { t.Run("invalid data read path", func(t *testing.T) { t.Parallel() - readData, err := readSecretData[p2p.NodeKey]("") + readData, err := readSecretData[types.NodeKey]("") assert.Nil(t, readData) assert.ErrorContains( @@ -44,7 +44,7 @@ func TestCommon_SaveReadData(t *testing.T) { require.NoError(t, saveSecretData("totally valid key", path)) - readData, err := readSecretData[p2p.NodeKey](path) + readData, err := readSecretData[types.NodeKey](path) require.Nil(t, readData) assert.ErrorContains(t, err, "unable to unmarshal data") @@ -59,7 +59,7 @@ func TestCommon_SaveReadData(t *testing.T) { require.NoError(t, saveSecretData(key, path)) - readKey, err := readSecretData[p2p.NodeKey](path) + readKey, err := readSecretData[types.NodeKey](path) require.NoError(t, err) assert.Equal(t, key, readKey) diff --git a/gno.land/cmd/gnoland/secrets_get.go b/gno.land/cmd/gnoland/secrets_get.go index 8d111516816..0a0a714f6ee 100644 --- a/gno.land/cmd/gnoland/secrets_get.go +++ b/gno.land/cmd/gnoland/secrets_get.go @@ -12,7 +12,7 @@ import ( "github.com/gnolang/gno/tm2/pkg/bft/privval" "github.com/gnolang/gno/tm2/pkg/commands" osm "github.com/gnolang/gno/tm2/pkg/os" - "github.com/gnolang/gno/tm2/pkg/p2p" + "github.com/gnolang/gno/tm2/pkg/p2p/types" ) var errInvalidSecretsGetArgs = errors.New("invalid number of secrets get arguments provided") @@ -169,7 +169,7 @@ func readValidatorState(path string) (*validatorStateInfo, error) { // readNodeID reads the node p2p info from the given path func readNodeID(path string) (*nodeIDInfo, error) { - nodeKey, err := readSecretData[p2p.NodeKey](path) + nodeKey, err := readSecretData[types.NodeKey](path) if err != nil { return nil, fmt.Errorf("unable to read node key, %w", err) } @@ -199,7 +199,7 @@ func readNodeID(path string) (*nodeIDInfo, error) { // constructP2PAddress constructs the P2P address other nodes can use // to connect directly -func constructP2PAddress(nodeID p2p.ID, listenAddress string) string { +func constructP2PAddress(nodeID types.ID, listenAddress string) string { var ( address string parts = strings.SplitN(listenAddress, "://", 2) diff --git a/gno.land/cmd/gnoland/secrets_get_test.go b/gno.land/cmd/gnoland/secrets_get_test.go index 66e6e3509fc..3dfe0c727dd 100644 --- a/gno.land/cmd/gnoland/secrets_get_test.go +++ b/gno.land/cmd/gnoland/secrets_get_test.go @@ -13,7 +13,7 @@ import ( "github.com/gnolang/gno/tm2/pkg/bft/config" "github.com/gnolang/gno/tm2/pkg/bft/privval" "github.com/gnolang/gno/tm2/pkg/commands" - "github.com/gnolang/gno/tm2/pkg/p2p" + "github.com/gnolang/gno/tm2/pkg/p2p/types" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -66,7 +66,7 @@ func TestSecrets_Get_All(t *testing.T) { // Get the node key nodeKeyPath := filepath.Join(tempDir, defaultNodeKeyName) - nodeKey, err := readSecretData[p2p.NodeKey](nodeKeyPath) + nodeKey, err := readSecretData[types.NodeKey](nodeKeyPath) require.NoError(t, err) // Get the validator private key diff --git a/gno.land/cmd/gnoland/secrets_init.go b/gno.land/cmd/gnoland/secrets_init.go index 58dd0783f66..9a7ddd106c3 100644 --- a/gno.land/cmd/gnoland/secrets_init.go +++ b/gno.land/cmd/gnoland/secrets_init.go @@ -12,7 +12,7 @@ import ( "github.com/gnolang/gno/tm2/pkg/commands" "github.com/gnolang/gno/tm2/pkg/crypto/ed25519" osm "github.com/gnolang/gno/tm2/pkg/os" - "github.com/gnolang/gno/tm2/pkg/p2p" + "github.com/gnolang/gno/tm2/pkg/p2p/types" ) var errOverwriteNotEnabled = errors.New("overwrite not enabled") @@ -200,10 +200,6 @@ func generateLastSignValidatorState() *privval.FilePVLastSignState { } // generateNodeKey generates the p2p node key -func generateNodeKey() *p2p.NodeKey { - privKey := ed25519.GenPrivKey() - - return &p2p.NodeKey{ - PrivKey: privKey, - } +func generateNodeKey() *types.NodeKey { + return types.GenerateNodeKey() } diff --git a/gno.land/cmd/gnoland/secrets_init_test.go b/gno.land/cmd/gnoland/secrets_init_test.go index 20e061447f5..7be3650fb4b 100644 --- a/gno.land/cmd/gnoland/secrets_init_test.go +++ b/gno.land/cmd/gnoland/secrets_init_test.go @@ -7,7 +7,7 @@ import ( "github.com/gnolang/gno/tm2/pkg/bft/privval" "github.com/gnolang/gno/tm2/pkg/commands" - "github.com/gnolang/gno/tm2/pkg/p2p" + "github.com/gnolang/gno/tm2/pkg/p2p/types" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -37,7 +37,7 @@ func verifyValidatorState(t *testing.T, path string) { func verifyNodeKey(t *testing.T, path string) { t.Helper() - nodeKey, err := readSecretData[p2p.NodeKey](path) + nodeKey, err := readSecretData[types.NodeKey](path) require.NoError(t, err) assert.NoError(t, validateNodeKey(nodeKey)) diff --git a/gno.land/cmd/gnoland/secrets_verify.go b/gno.land/cmd/gnoland/secrets_verify.go index 32e563c1c6f..15fef6649ec 100644 --- a/gno.land/cmd/gnoland/secrets_verify.go +++ b/gno.land/cmd/gnoland/secrets_verify.go @@ -8,7 +8,7 @@ import ( "github.com/gnolang/gno/tm2/pkg/bft/privval" "github.com/gnolang/gno/tm2/pkg/commands" - "github.com/gnolang/gno/tm2/pkg/p2p" + "github.com/gnolang/gno/tm2/pkg/p2p/types" ) type secretsVerifyCfg struct { @@ -146,7 +146,7 @@ func readAndVerifyValidatorState(path string, io commands.IO) (*privval.FilePVLa // readAndVerifyNodeKey reads the node p2p key from the given path and verifies it func readAndVerifyNodeKey(path string, io commands.IO) error { - nodeKey, err := readSecretData[p2p.NodeKey](path) + nodeKey, err := readSecretData[types.NodeKey](path) if err != nil { return fmt.Errorf("unable to read node p2p key, %w", err) } diff --git a/gno.land/cmd/gnoland/secrets_verify_test.go b/gno.land/cmd/gnoland/secrets_verify_test.go index 513d7c8b503..67630aaaa4a 100644 --- a/gno.land/cmd/gnoland/secrets_verify_test.go +++ b/gno.land/cmd/gnoland/secrets_verify_test.go @@ -8,7 +8,7 @@ import ( "github.com/gnolang/gno/tm2/pkg/bft/privval" "github.com/gnolang/gno/tm2/pkg/commands" - "github.com/gnolang/gno/tm2/pkg/p2p" + "github.com/gnolang/gno/tm2/pkg/p2p/types" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -347,7 +347,7 @@ func TestSecrets_Verify_Single(t *testing.T) { dirPath := t.TempDir() path := filepath.Join(dirPath, defaultNodeKeyName) - invalidNodeKey := &p2p.NodeKey{ + invalidNodeKey := &types.NodeKey{ PrivKey: nil, // invalid } diff --git a/gno.land/cmd/gnoland/start.go b/gno.land/cmd/gnoland/start.go index 77d7e20b8ef..4f380031be4 100644 --- a/gno.land/cmd/gnoland/start.go +++ b/gno.land/cmd/gnoland/start.go @@ -26,6 +26,7 @@ import ( "github.com/gnolang/gno/tm2/pkg/crypto" "github.com/gnolang/gno/tm2/pkg/events" osm "github.com/gnolang/gno/tm2/pkg/os" + "github.com/gnolang/gno/tm2/pkg/std" "github.com/gnolang/gno/tm2/pkg/telemetry" "go.uber.org/zap" @@ -44,22 +45,20 @@ var startGraphic = strings.ReplaceAll(` /___/ `, "'", "`") -var ( - // Keep in sync with contribs/gnogenesis/internal/txs/txs_add_packages.go - genesisDeployAddress = crypto.MustAddressFromString("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5") // test1 - genesisDeployFee = std.NewFee(50000, std.MustParseCoin(ugnot.ValueString(1000000))) -) +// Keep in sync with contribs/gnogenesis/internal/txs/txs_add_packages.go +var genesisDeployFee = std.NewFee(50000, std.MustParseCoin(ugnot.ValueString(1000000))) type startCfg struct { - gnoRootDir string // TODO: remove as part of https://github.com/gnolang/gno/issues/1952 - skipFailingGenesisTxs bool // TODO: remove as part of https://github.com/gnolang/gno/issues/1952 - genesisBalancesFile string // TODO: remove as part of https://github.com/gnolang/gno/issues/1952 - genesisTxsFile string // TODO: remove as part of https://github.com/gnolang/gno/issues/1952 - genesisRemote string // TODO: remove as part of https://github.com/gnolang/gno/issues/1952 - genesisFile string - chainID string - dataDir string - lazyInit bool + gnoRootDir string // TODO: remove as part of https://github.com/gnolang/gno/issues/1952 + skipFailingGenesisTxs bool // TODO: remove as part of https://github.com/gnolang/gno/issues/1952 + skipGenesisSigVerification bool // TODO: remove as part of https://github.com/gnolang/gno/issues/1952 + genesisBalancesFile string // TODO: remove as part of https://github.com/gnolang/gno/issues/1952 + genesisTxsFile string // TODO: remove as part of https://github.com/gnolang/gno/issues/1952 + genesisRemote string // TODO: remove as part of https://github.com/gnolang/gno/issues/1952 + genesisFile string + chainID string + dataDir string + lazyInit bool logLevel string logFormat string @@ -85,7 +84,6 @@ func newStartCmd(io commands.IO) *commands.Command { func (c *startCfg) RegisterFlags(fs *flag.FlagSet) { gnoroot := gnoenv.RootDir() defaultGenesisBalancesFile := filepath.Join(gnoroot, "gno.land", "genesis", "genesis_balances.txt") - defaultGenesisTxsFile := filepath.Join(gnoroot, "gno.land", "genesis", "genesis_txs.jsonl") fs.BoolVar( &c.skipFailingGenesisTxs, @@ -94,6 +92,13 @@ func (c *startCfg) RegisterFlags(fs *flag.FlagSet) { "don't panic when replaying invalid genesis txs", ) + fs.BoolVar( + &c.skipGenesisSigVerification, + "skip-genesis-sig-verification", + false, + "don't panic when replaying invalidly signed genesis txs", + ) + fs.StringVar( &c.genesisBalancesFile, "genesis-balances-file", @@ -104,7 +109,7 @@ func (c *startCfg) RegisterFlags(fs *flag.FlagSet) { fs.StringVar( &c.genesisTxsFile, "genesis-txs-file", - defaultGenesisTxsFile, + "", "initial txs to replay", ) @@ -217,7 +222,7 @@ func execStart(ctx context.Context, c *startCfg, io commands.IO) error { ) // Init a new genesis.json - if err := lazyInitGenesis(io, c, genesisPath, privateKey.GetPubKey()); err != nil { + if err := lazyInitGenesis(io, c, genesisPath, privateKey.Key.PrivKey); err != nil { return fmt.Errorf("unable to initialize genesis.json, %w", err) } } @@ -234,9 +239,19 @@ func execStart(ctx context.Context, c *startCfg, io commands.IO) error { // Create a top-level shared event switch evsw := events.NewEventSwitch() + minGasPrices := cfg.Application.MinGasPrices // Create application and node - cfg.LocalApp, err = gnoland.NewApp(nodeDir, c.skipFailingGenesisTxs, evsw, logger) + cfg.LocalApp, err = gnoland.NewApp( + nodeDir, + gnoland.GenesisAppConfig{ + SkipFailingTxs: c.skipFailingGenesisTxs, + SkipSigVerification: c.skipGenesisSigVerification, + }, + evsw, + logger, + minGasPrices, + ) if err != nil { return fmt.Errorf("unable to create the Gnoland app, %w", err) } @@ -332,7 +347,7 @@ func lazyInitGenesis( io commands.IO, c *startCfg, genesisPath string, - publicKey crypto.PubKey, + privateKey crypto.PrivKey, ) error { // Check if the genesis.json is present if osm.FileExists(genesisPath) { @@ -340,7 +355,7 @@ func lazyInitGenesis( } // Generate the new genesis.json file - if err := generateGenesisFile(genesisPath, publicKey, c); err != nil { + if err := generateGenesisFile(genesisPath, privateKey, c); err != nil { return fmt.Errorf("unable to generate genesis file, %w", err) } @@ -365,24 +380,38 @@ func initializeLogger(io io.WriteCloser, logLevel, logFormat string) (*zap.Logge return log.GetZapLoggerFn(format)(io, level), nil } -func generateGenesisFile(genesisFile string, pk crypto.PubKey, c *startCfg) error { +func generateGenesisFile(genesisFile string, privKey crypto.PrivKey, c *startCfg) error { + var ( + pubKey = privKey.PubKey() + // There is an active constraint for gno.land transactions: + // + // All transaction messages' (MsgSend, MsgAddPkg...) "author" field, + // specific to the message type ("creator", "sender"...), must match + // the signature address contained in the transaction itself. + // This means that if MsgSend is originating from address A, + // the owner of the private key for address A needs to sign the transaction + // containing the message. Every message in a transaction needs to + // originate from the same account that signed the transaction + txSender = pubKey.Address() + ) + gen := &bft.GenesisDoc{} gen.GenesisTime = time.Now() gen.ChainID = c.chainID gen.ConsensusParams = abci.ConsensusParams{ Block: &abci.BlockParams{ // TODO: update limits. - MaxTxBytes: 1_000_000, // 1MB, - MaxDataBytes: 2_000_000, // 2MB, - MaxGas: 100_000_000, // 100M gas - TimeIotaMS: 100, // 100ms + MaxTxBytes: 1_000_000, // 1MB, + MaxDataBytes: 2_000_000, // 2MB, + MaxGas: 3_000_000_000, // 3B gas + TimeIotaMS: 100, // 100ms }, } gen.Validators = []bft.GenesisValidator{ { - Address: pk.Address(), - PubKey: pk, + Address: pubKey.Address(), + PubKey: pubKey, Power: 10, Name: "testvalidator", }, @@ -396,25 +425,46 @@ func generateGenesisFile(genesisFile string, pk crypto.PubKey, c *startCfg) erro // Load examples folder examplesDir := filepath.Join(c.gnoRootDir, "examples") - pkgsTxs, err := gnoland.LoadPackagesFromDir(examplesDir, genesisDeployAddress, genesisDeployFee) + pkgsTxs, err := gnoland.LoadPackagesFromDir(examplesDir, txSender, genesisDeployFee) if err != nil { return fmt.Errorf("unable to load examples folder: %w", err) } // Load Genesis TXs - genesisTxs, err := gnoland.LoadGenesisTxsFile(c.genesisTxsFile, c.chainID, c.genesisRemote) - if err != nil { - return fmt.Errorf("unable to load genesis txs file: %w", err) + var genesisTxs []gnoland.TxWithMetadata + + if c.genesisTxsFile != "" { + genesisTxs, err = gnoland.LoadGenesisTxsFile(c.genesisTxsFile, c.chainID, c.genesisRemote) + if err != nil { + return fmt.Errorf("unable to load genesis txs file: %w", err) + } } genesisTxs = append(pkgsTxs, genesisTxs...) - // Construct genesis AppState. - gen.AppState = gnoland.GnoGenesisState{ - Balances: balances, - Txs: genesisTxs, + // Sign genesis transactions, with the default key (test1) + if err = gnoland.SignGenesisTxs(genesisTxs, privKey, c.chainID); err != nil { + return fmt.Errorf("unable to sign genesis txs: %w", err) } + // Make sure the genesis transaction author has sufficient + // balance to cover transaction deployments in genesis. + // + // During the init-chainer process, the account that authors the + // genesis transactions needs to have a sufficient balance + // to cover outstanding transaction costs. + // Since the cost can't be estimated upfront at this point, the balance + // set is an arbitrary value based on a "best guess" basis. + // There should be a larger discussion if genesis transactions should consume gas, at all + deployerBalance := int64(len(genesisTxs)) * 10_000_000 // ~10 GNOT per tx + balances.Set(txSender, std.NewCoins(std.NewCoin("ugnot", deployerBalance))) + + // Construct genesis AppState. + defaultGenState := gnoland.DefaultGenState() + defaultGenState.Balances = balances.List() + defaultGenState.Txs = genesisTxs + gen.AppState = defaultGenState + // Write genesis state if err := gen.SaveAs(genesisFile); err != nil { return fmt.Errorf("unable to write genesis file %q: %w", genesisFile, err) diff --git a/gno.land/cmd/gnoweb/CONTRIBUTING.md b/gno.land/cmd/gnoweb/CONTRIBUTING.md deleted file mode 100644 index 7d7663e8bf7..00000000000 --- a/gno.land/cmd/gnoweb/CONTRIBUTING.md +++ /dev/null @@ -1,20 +0,0 @@ -# gno.land Website - -The gno.land website has 3 main dependencies: - -1. [UmbrellaJs](https://umbrellajs.com/) for DOM operations -2. [MarkedJs](https://marked.js.org/) for Markdown to html compilation -3. [HighlightJs](https://highlightjs.org/) for golang syntax highlighting -4. [DOMPurify](https://github.com/cure53/DOMPurify) to sanitize html (and avoid xss) - -Some security considerations: -| | Umbrella Js | Marked Js | HighlightJs | DOMPurify | -|---|---|---|---|---| -| dependencies | 0 | 0 | 0 | 0 | -| sanitize content | | [no](https://marked.js.org/#usage) | [throws an error](https://github.com/highlightjs/highlight.js/blob/7addd66c19036eccd7c602af61f1ed84d215c77d/src/highlight.js#L741) | [yes](https://github.com/cure53/DOMPurify#readme) | - -Best Practices: - -- **When using MarkedJs**: Always run the output of the marked compiler inside `DOMPurify.sanitize` before inserting it in the dom with `.innerHtml = `. -- **When using DOMPurify**: Preferably use `{ USE_PROFILES: { html: true } }` option to allow html only. Content passed in the sanitizer must not be modified afterwards, and must directly be inserted in the DOM with innerHtml. Do not call `DOMPurify.sanitize` with the output of a previous `DOMPurify.sanitize` to avoid any mutation XSS risks. -- **When using HighlightJs**: always configure it before with `hljs.configure({throwUnescapedHTML: true})` to throw before inserting html in the page if any unexpected html children are detected. The check is done [here](https://github.com/highlightjs/highlight.js/blob/7addd66c19036eccd7c602af61f1ed84d215c77d/src/highlight.js#L741). diff --git a/gno.land/cmd/gnoweb/README.md b/gno.land/cmd/gnoweb/README.md index 6379d3f6c43..ccd538c8f70 100644 --- a/gno.land/cmd/gnoweb/README.md +++ b/gno.land/cmd/gnoweb/README.md @@ -2,12 +2,4 @@ The gno.land web interface. -Live demo: https://gno.land/ - -## Install `gnoweb` - -Install and run a local [`gnoland`](../gnoland) instance first. - - $> git clone git@github.com:gnolang/gno.git - $> cd ./gno/gno.land - $> make install.gnoweb +Live demo: [https://gno.land/](https://gno.land/) or using `gnodev` from the directory [gnodev](../../../contribs/gnodev). diff --git a/gno.land/cmd/gnoweb/main.go b/gno.land/cmd/gnoweb/main.go index 5cec7257ebe..8c0df00aa35 100644 --- a/gno.land/cmd/gnoweb/main.go +++ b/gno.land/cmd/gnoweb/main.go @@ -1,61 +1,196 @@ package main import ( + "context" "flag" "fmt" + "net" "net/http" "os" "time" - // for static files "github.com/gnolang/gno/gno.land/pkg/gnoweb" "github.com/gnolang/gno/gno.land/pkg/log" + "github.com/gnolang/gno/tm2/pkg/commands" + "go.uber.org/zap" "go.uber.org/zap/zapcore" - // for error types - // "github.com/gnolang/gno/tm2/pkg/sdk" // for baseapp (info, status) ) +type webCfg struct { + chainid string + remote string + remoteHelp string + bind string + faucetURL string + assetsDir string + analytics bool + json bool + html bool + verbose bool +} + +var defaultWebOptions = webCfg{ + chainid: "dev", + remote: "127.0.0.1:26657", + bind: ":8888", +} + func main() { - err := runMain(os.Args[1:]) - if err != nil { - _, _ = fmt.Fprintf(os.Stderr, "%+v\n", err) - os.Exit(1) - } + var cfg webCfg + + stdio := commands.NewDefaultIO() + cmd := commands.NewCommand( + commands.Metadata{ + Name: "gnoweb", + ShortUsage: "gnoweb [flags] [path ...]", + ShortHelp: "runs gno.land web interface", + LongHelp: `gnoweb web interface`, + }, + &cfg, + func(ctx context.Context, args []string) error { + run, err := setupWeb(&cfg, args, stdio) + if err != nil { + return err + } + + return run() + }) + + cmd.Execute(context.Background(), os.Args[1:]) } -func runMain(args []string) error { - var ( - fs = flag.NewFlagSet("gnoweb", flag.ContinueOnError) - cfg = gnoweb.NewDefaultConfig() - bindAddress string - ) - fs.StringVar(&cfg.RemoteAddr, "remote", cfg.RemoteAddr, "remote gnoland node address") - fs.StringVar(&cfg.CaptchaSite, "captcha-site", cfg.CaptchaSite, "recaptcha site key (if empty, captcha are disabled)") - fs.StringVar(&cfg.FaucetURL, "faucet-url", cfg.FaucetURL, "faucet server URL") - fs.StringVar(&cfg.ViewsDir, "views-dir", cfg.ViewsDir, "views directory location") // XXX: replace with goembed - fs.StringVar(&cfg.HelpChainID, "help-chainid", cfg.HelpChainID, "help page's chainid") - fs.StringVar(&cfg.HelpRemote, "help-remote", cfg.HelpRemote, "help page's remote addr") - fs.BoolVar(&cfg.WithAnalytics, "with-analytics", cfg.WithAnalytics, "enable privacy-first analytics") - fs.StringVar(&bindAddress, "bind", "127.0.0.1:8888", "server listening address") - fs.BoolVar(&cfg.WithHTML, "with-html", cfg.WithHTML, "Enable HTML parsing in markdown rendering") - - if err := fs.Parse(args); err != nil { - return err +func (c *webCfg) RegisterFlags(fs *flag.FlagSet) { + fs.StringVar( + &c.remote, + "remote", + defaultWebOptions.remote, + "remote gno.land node address", + ) + + fs.StringVar( + &c.remoteHelp, + "help-remote", + defaultWebOptions.remoteHelp, + "help page's remote address", + ) + + fs.StringVar( + &c.assetsDir, + "assets-dir", + defaultWebOptions.assetsDir, + "if not empty, will be use as assets directory", + ) + + fs.StringVar( + &c.chainid, + "help-chainid", + defaultWebOptions.chainid, + "Deprecated: use `chainid` instead", + ) + + fs.StringVar( + &c.chainid, + "chainid", + defaultWebOptions.chainid, + "target chain id", + ) + + fs.StringVar( + &c.bind, + "bind", + defaultWebOptions.bind, + "gnoweb listener", + ) + + fs.StringVar( + &c.faucetURL, + "faucet-url", + defaultWebOptions.faucetURL, + "The faucet URL will redirect the user when they access `/faucet`.", + ) + + fs.BoolVar( + &c.json, + "json", + defaultWebOptions.json, + "display log in json format", + ) + + fs.BoolVar( + &c.html, + "html", + defaultWebOptions.html, + "enable unsafe html", + ) + + fs.BoolVar( + &c.analytics, + "with-analytics", + defaultWebOptions.analytics, + "nable privacy-first analytics", + ) + + fs.BoolVar( + &c.verbose, + "v", + defaultWebOptions.verbose, + "verbose logging mode", + ) +} + +func setupWeb(cfg *webCfg, _ []string, io commands.IO) (func() error, error) { + // Setup logger + level := zapcore.InfoLevel + if cfg.verbose { + level = zapcore.DebugLevel + } + var zapLogger *zap.Logger + if cfg.json { + zapLogger = log.NewZapJSONLogger(io.Out(), level) + } else { + zapLogger = log.NewZapConsoleLogger(io.Out(), level) } + defer zapLogger.Sync() - zapLogger := log.NewZapConsoleLogger(os.Stdout, zapcore.DebugLevel) logger := log.ZapLoggerToSlog(zapLogger) - logger.Info("Running", "listener", "http://"+bindAddress) - server := &http.Server{ - Addr: bindAddress, - ReadHeaderTimeout: 60 * time.Second, - Handler: gnoweb.MakeApp(logger, cfg).Router, + // Setup app + appcfg := gnoweb.NewDefaultAppConfig() + appcfg.ChainID = cfg.chainid + appcfg.NodeRemote = cfg.remote + appcfg.RemoteHelp = cfg.remoteHelp + if appcfg.RemoteHelp == "" { + appcfg.RemoteHelp = appcfg.NodeRemote + } + appcfg.Analytics = cfg.analytics + appcfg.UnsafeHTML = cfg.html + appcfg.FaucetURL = cfg.faucetURL + appcfg.AssetsDir = cfg.assetsDir + app, err := gnoweb.NewRouter(logger, appcfg) + if err != nil { + return nil, fmt.Errorf("unable to start gnoweb app: %w", err) + } + + // Resolve binding address + bindaddr, err := net.ResolveTCPAddr("tcp", cfg.bind) + if err != nil { + return nil, fmt.Errorf("unable to resolve listener %q: %w", cfg.bind, err) } - if err := server.ListenAndServe(); err != nil { - logger.Error("HTTP server stopped", " error:", err) + logger.Info("Running", "listener", bindaddr.String()) + + // Setup server + server := &http.Server{ + Handler: app, + Addr: bindaddr.String(), + ReadHeaderTimeout: 60 * time.Second, } - return zapLogger.Sync() + return func() error { + if err := server.ListenAndServe(); err != nil { + logger.Error("HTTP server stopped", "error", err) + return commands.ExitCodeError(1) + } + return nil + }, nil } diff --git a/gno.land/cmd/gnoweb/main_test.go b/gno.land/cmd/gnoweb/main_test.go index 640c4763140..37006c18c93 100644 --- a/gno.land/cmd/gnoweb/main_test.go +++ b/gno.land/cmd/gnoweb/main_test.go @@ -1,14 +1,25 @@ package main import ( - "errors" - "flag" + "os" "testing" + + "github.com/gnolang/gno/tm2/pkg/commands" + "github.com/stretchr/testify/require" ) -func TestFlagHelp(t *testing.T) { - err := runMain([]string{"-h"}) - if !errors.Is(err, flag.ErrHelp) { - t.Errorf("should display usage") - } +func TestSetupWeb(t *testing.T) { + opts := defaultWebOptions + opts.bind = "127.0.0.1:0" // random port + stdio := commands.NewDefaultIO() + + // Open /dev/null as a write-only file + devNull, err := os.OpenFile(os.DevNull, os.O_WRONLY, 0o644) + require.NoError(t, err) + defer devNull.Close() + + stdio.SetOut(devNull) + + _, err = setupWeb(&opts, []string{}, stdio) + require.NoError(t, err) } diff --git a/gno.land/pkg/gnoclient/client_test.go b/gno.land/pkg/gnoclient/client_test.go index 1f8563d34fe..54a15420a66 100644 --- a/gno.land/pkg/gnoclient/client_test.go +++ b/gno.land/pkg/gnoclient/client_test.go @@ -1,8 +1,11 @@ package gnoclient import ( + "errors" "testing" + "github.com/gnolang/gno/tm2/pkg/amino" + abciErrors "github.com/gnolang/gno/tm2/pkg/bft/abci/example/errors" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -1409,3 +1412,157 @@ func addPackageSigningSeparately(t *testing.T, client Client, cfg BaseTxCfg, msg require.NotNil(t, res) return res, nil } + +func TestClient_EstimateGas(t *testing.T) { + t.Parallel() + + t.Run("RPC client not set", func(t *testing.T) { + t.Parallel() + + c := &Client{ + RPCClient: nil, // not set + } + + estimate, err := c.EstimateGas(&std.Tx{}) + + assert.Zero(t, estimate) + assert.ErrorIs(t, err, ErrMissingRPCClient) + }) + + t.Run("unsuccessful query, rpc error", func(t *testing.T) { + t.Parallel() + + var ( + rpcErr = errors.New("rpc error") + mockRPCClient = &mockRPCClient{ + abciQuery: func(path string, data []byte) (*ctypes.ResultABCIQuery, error) { + require.Equal(t, simulatePath, path) + + var tx std.Tx + + require.NoError(t, amino.Unmarshal(data, &tx)) + + return nil, rpcErr + }, + } + ) + + c := &Client{ + RPCClient: mockRPCClient, + } + + estimate, err := c.EstimateGas(&std.Tx{}) + + assert.Zero(t, estimate) + assert.ErrorIs(t, err, rpcErr) + }) + + t.Run("unsuccessful query, process error", func(t *testing.T) { + t.Parallel() + + var ( + response = &ctypes.ResultABCIQuery{ + Response: abci.ResponseQuery{ + ResponseBase: abci.ResponseBase{ + Error: abciErrors.UnknownError{}, + }, + }, + } + mockRPCClient = &mockRPCClient{ + abciQuery: func(path string, data []byte) (*ctypes.ResultABCIQuery, error) { + require.Equal(t, simulatePath, path) + + var tx std.Tx + + require.NoError(t, amino.Unmarshal(data, &tx)) + + return response, nil + }, + } + ) + + c := &Client{ + RPCClient: mockRPCClient, + } + + estimate, err := c.EstimateGas(&std.Tx{}) + + assert.Zero(t, estimate) + assert.ErrorIs(t, err, abciErrors.UnknownError{}) + }) + + t.Run("invalid response format", func(t *testing.T) { + t.Parallel() + + var ( + response = &ctypes.ResultABCIQuery{ + Response: abci.ResponseQuery{ + Value: []byte("totally valid amino"), + }, + } + mockRPCClient = &mockRPCClient{ + abciQuery: func(path string, data []byte) (*ctypes.ResultABCIQuery, error) { + require.Equal(t, simulatePath, path) + + var tx std.Tx + + require.NoError(t, amino.Unmarshal(data, &tx)) + + return response, nil + }, + } + ) + + c := &Client{ + RPCClient: mockRPCClient, + } + + estimate, err := c.EstimateGas(&std.Tx{}) + + assert.Zero(t, estimate) + assert.ErrorContains(t, err, "unable to unmarshal gas estimation response") + }) + + t.Run("valid gas estimation", func(t *testing.T) { + t.Parallel() + + var ( + gasUsed = int64(100000) + deliverResp = &abci.ResponseDeliverTx{ + GasUsed: gasUsed, + } + ) + + // Encode the response + encodedResp, err := amino.Marshal(deliverResp) + require.NoError(t, err) + + var ( + response = &ctypes.ResultABCIQuery{ + Response: abci.ResponseQuery{ + Value: encodedResp, // valid amino binary + }, + } + mockRPCClient = &mockRPCClient{ + abciQuery: func(path string, data []byte) (*ctypes.ResultABCIQuery, error) { + require.Equal(t, simulatePath, path) + + var tx std.Tx + + require.NoError(t, amino.Unmarshal(data, &tx)) + + return response, nil + }, + } + ) + + c := &Client{ + RPCClient: mockRPCClient, + } + + estimate, err := c.EstimateGas(&std.Tx{}) + + require.NoError(t, err) + assert.Equal(t, gasUsed, estimate) + }) +} diff --git a/gno.land/pkg/gnoclient/client_txs.go b/gno.land/pkg/gnoclient/client_txs.go index d7f6f053242..ab520eceda1 100644 --- a/gno.land/pkg/gnoclient/client_txs.go +++ b/gno.land/pkg/gnoclient/client_txs.go @@ -1,8 +1,11 @@ package gnoclient import ( + "fmt" + "github.com/gnolang/gno/gno.land/pkg/sdk/vm" "github.com/gnolang/gno/tm2/pkg/amino" + abci "github.com/gnolang/gno/tm2/pkg/bft/abci/types" ctypes "github.com/gnolang/gno/tm2/pkg/bft/rpc/core/types" "github.com/gnolang/gno/tm2/pkg/errors" "github.com/gnolang/gno/tm2/pkg/sdk/bank" @@ -16,6 +19,8 @@ var ( ErrMissingRPCClient = errors.New("missing RPCClient") ) +const simulatePath = ".app/simulate" + // BaseTxCfg defines the base transaction configuration, shared by all message types type BaseTxCfg struct { GasFee string // Gas fee @@ -292,4 +297,44 @@ func (c *Client) BroadcastTxCommit(signedTx *std.Tx) (*ctypes.ResultBroadcastTxC return bres, nil } -// TODO: Add more functionality, examples, and unit tests. +// EstimateGas returns the least amount of gas required +// for the transaction to go through on the chain (minimum gas wanted). +// The estimation process assumes the transaction is properly signed +func (c *Client) EstimateGas(tx *std.Tx) (int64, error) { + // Make sure the RPC client is set + if err := c.validateRPCClient(); err != nil { + return 0, err + } + + // Prepare the transaction. + // The transaction needs to be amino-binary encoded + // in order to be estimated + encodedTx, err := amino.Marshal(tx) + if err != nil { + return 0, fmt.Errorf("unable to marshal tx: %w", err) + } + + // Perform the simulation query + resp, err := c.RPCClient.ABCIQuery(simulatePath, encodedTx) + if err != nil { + return 0, fmt.Errorf("unable to perform ABCI query: %w", err) + } + + // Extract the query response + if err = resp.Response.Error; err != nil { + return 0, fmt.Errorf("error encountered during ABCI query: %w", err) + } + + var deliverTx abci.ResponseDeliverTx + if err = amino.Unmarshal(resp.Response.Value, &deliverTx); err != nil { + return 0, fmt.Errorf("unable to unmarshal gas estimation response: %w", err) + } + + if err = deliverTx.Error; err != nil { + return 0, fmt.Errorf("error encountered during gas estimation: %w", err) + } + + // Return the actual value returned by the node + // for executing the transaction + return deliverTx.GasUsed, nil +} diff --git a/gno.land/pkg/gnoclient/integration_test.go b/gno.land/pkg/gnoclient/integration_test.go index 0a06eb4756a..bfcaaec999e 100644 --- a/gno.land/pkg/gnoclient/integration_test.go +++ b/gno.land/pkg/gnoclient/integration_test.go @@ -1,10 +1,12 @@ package gnoclient import ( + "path/filepath" "testing" "github.com/gnolang/gno/gnovm/pkg/gnolang" + "github.com/gnolang/gno/gno.land/pkg/gnoland" "github.com/gnolang/gno/gno.land/pkg/gnoland/ugnot" "github.com/gnolang/gno/gno.land/pkg/integration" "github.com/gnolang/gno/gno.land/pkg/sdk/vm" @@ -21,8 +23,14 @@ import ( ) func TestCallSingle_Integration(t *testing.T) { - // Set up in-memory node - config, _ := integration.TestingNodeConfig(t, gnoenv.RootDir()) + // Setup packages + rootdir := gnoenv.RootDir() + config := integration.TestingMinimalNodeConfig(gnoenv.RootDir()) + meta := loadpkgs(t, rootdir, "gno.land/r/demo/deep/very/deep") + state := config.Genesis.AppState.(gnoland.GnoGenesisState) + state.Txs = append(state.Txs, meta...) + config.Genesis.AppState = state + node, remoteAddr := integration.TestingInMemoryNode(t, log.NewNoopLogger(), config) defer node.Stop() @@ -39,8 +47,8 @@ func TestCallSingle_Integration(t *testing.T) { // Make Tx config baseCfg := BaseTxCfg{ - GasFee: ugnot.ValueString(10000), - GasWanted: 8000000, + GasFee: ugnot.ValueString(2100000), + GasWanted: 21000000, AccountNumber: 0, SequenceNumber: 0, Memo: "", @@ -74,8 +82,14 @@ func TestCallSingle_Integration(t *testing.T) { } func TestCallMultiple_Integration(t *testing.T) { - // Set up in-memory node - config, _ := integration.TestingNodeConfig(t, gnoenv.RootDir()) + // Setup packages + rootdir := gnoenv.RootDir() + config := integration.TestingMinimalNodeConfig(gnoenv.RootDir()) + meta := loadpkgs(t, rootdir, "gno.land/r/demo/deep/very/deep") + state := config.Genesis.AppState.(gnoland.GnoGenesisState) + state.Txs = append(state.Txs, meta...) + config.Genesis.AppState = state + node, remoteAddr := integration.TestingInMemoryNode(t, log.NewNoopLogger(), config) defer node.Stop() @@ -92,8 +106,8 @@ func TestCallMultiple_Integration(t *testing.T) { // Make Tx config baseCfg := BaseTxCfg{ - GasFee: ugnot.ValueString(10000), - GasWanted: 8000000, + GasFee: ugnot.ValueString(2100000), + GasWanted: 21000000, AccountNumber: 0, SequenceNumber: 0, Memo: "", @@ -137,7 +151,7 @@ func TestCallMultiple_Integration(t *testing.T) { func TestSendSingle_Integration(t *testing.T) { // Set up in-memory node - config, _ := integration.TestingNodeConfig(t, gnoenv.RootDir()) + config := integration.TestingMinimalNodeConfig(gnoenv.RootDir()) node, remoteAddr := integration.TestingInMemoryNode(t, log.NewNoopLogger(), config) defer node.Stop() @@ -154,8 +168,8 @@ func TestSendSingle_Integration(t *testing.T) { // Make Tx config baseCfg := BaseTxCfg{ - GasFee: ugnot.ValueString(10000), - GasWanted: 8000000, + GasFee: ugnot.ValueString(2100000), + GasWanted: 21000000, AccountNumber: 0, SequenceNumber: 0, Memo: "", @@ -201,7 +215,7 @@ func TestSendSingle_Integration(t *testing.T) { func TestSendMultiple_Integration(t *testing.T) { // Set up in-memory node - config, _ := integration.TestingNodeConfig(t, gnoenv.RootDir()) + config := integration.TestingMinimalNodeConfig(gnoenv.RootDir()) node, remoteAddr := integration.TestingInMemoryNode(t, log.NewNoopLogger(), config) defer node.Stop() @@ -218,8 +232,8 @@ func TestSendMultiple_Integration(t *testing.T) { // Make Tx config baseCfg := BaseTxCfg{ - GasFee: ugnot.ValueString(10000), - GasWanted: 8000000, + GasFee: ugnot.ValueString(2100000), + GasWanted: 21000000, AccountNumber: 0, SequenceNumber: 0, Memo: "", @@ -273,8 +287,15 @@ func TestSendMultiple_Integration(t *testing.T) { // Run tests func TestRunSingle_Integration(t *testing.T) { + // Setup packages + rootdir := gnoenv.RootDir() + config := integration.TestingMinimalNodeConfig(gnoenv.RootDir()) + meta := loadpkgs(t, rootdir, "gno.land/p/demo/ufmt", "gno.land/r/demo/tests") + state := config.Genesis.AppState.(gnoland.GnoGenesisState) + state.Txs = append(state.Txs, meta...) + config.Genesis.AppState = state + // Set up in-memory node - config, _ := integration.TestingNodeConfig(t, gnoenv.RootDir()) node, remoteAddr := integration.TestingInMemoryNode(t, log.NewNoopLogger(), config) defer node.Stop() @@ -290,8 +311,8 @@ func TestRunSingle_Integration(t *testing.T) { // Make Tx config baseCfg := BaseTxCfg{ - GasFee: ugnot.ValueString(10000), - GasWanted: 8000000, + GasFee: ugnot.ValueString(2100000), + GasWanted: 21000000, AccountNumber: 0, SequenceNumber: 0, Memo: "", @@ -342,7 +363,17 @@ func main() { // Run tests func TestRunMultiple_Integration(t *testing.T) { // Set up in-memory node - config, _ := integration.TestingNodeConfig(t, gnoenv.RootDir()) + rootdir := gnoenv.RootDir() + config := integration.TestingMinimalNodeConfig(rootdir) + meta := loadpkgs(t, rootdir, + "gno.land/p/demo/ufmt", + "gno.land/r/demo/tests", + "gno.land/r/demo/deep/very/deep", + ) + state := config.Genesis.AppState.(gnoland.GnoGenesisState) + state.Txs = append(state.Txs, meta...) + config.Genesis.AppState = state + node, remoteAddr := integration.TestingInMemoryNode(t, log.NewNoopLogger(), config) defer node.Stop() @@ -358,8 +389,8 @@ func TestRunMultiple_Integration(t *testing.T) { // Make Tx config baseCfg := BaseTxCfg{ - GasFee: ugnot.ValueString(10000), - GasWanted: 13000000, + GasFee: ugnot.ValueString(2300000), + GasWanted: 23000000, AccountNumber: 0, SequenceNumber: 0, Memo: "", @@ -434,7 +465,7 @@ func main() { func TestAddPackageSingle_Integration(t *testing.T) { // Set up in-memory node - config, _ := integration.TestingNodeConfig(t, gnoenv.RootDir()) + config := integration.TestingMinimalNodeConfig(gnoenv.RootDir()) node, remoteAddr := integration.TestingInMemoryNode(t, log.NewNoopLogger(), config) defer node.Stop() @@ -451,8 +482,8 @@ func TestAddPackageSingle_Integration(t *testing.T) { // Make Tx config baseCfg := BaseTxCfg{ - GasFee: ugnot.ValueString(10000), - GasWanted: 8000000, + GasFee: ugnot.ValueString(2100000), + GasWanted: 21000000, AccountNumber: 0, SequenceNumber: 0, Memo: "", @@ -519,7 +550,7 @@ func Echo(str string) string { func TestAddPackageMultiple_Integration(t *testing.T) { // Set up in-memory node - config, _ := integration.TestingNodeConfig(t, gnoenv.RootDir()) + config := integration.TestingMinimalNodeConfig(gnoenv.RootDir()) node, remoteAddr := integration.TestingInMemoryNode(t, log.NewNoopLogger(), config) defer node.Stop() @@ -536,8 +567,8 @@ func TestAddPackageMultiple_Integration(t *testing.T) { // Make Tx config baseCfg := BaseTxCfg{ - GasFee: ugnot.ValueString(10000), - GasWanted: 8000000, + GasFee: ugnot.ValueString(2100000), + GasWanted: 21000000, AccountNumber: 0, SequenceNumber: 0, Memo: "", @@ -670,3 +701,24 @@ func newInMemorySigner(t *testing.T, chainid string) *SignerFromKeybase { ChainID: chainid, // Chain ID for transaction signing } } + +func loadpkgs(t *testing.T, rootdir string, paths ...string) []gnoland.TxWithMetadata { + t.Helper() + + loader := integration.NewPkgsLoader() + examplesDir := filepath.Join(rootdir, "examples") + for _, path := range paths { + path = filepath.Clean(path) + path = filepath.Join(examplesDir, path) + err := loader.LoadPackage(examplesDir, path, "") + require.NoErrorf(t, err, "`loadpkg` unable to load package(s) from %q: %s", path, err) + } + privKey, err := integration.GeneratePrivKeyFromMnemonic(integration.DefaultAccount_Seed, "", 0, 0) + require.NoError(t, err) + + defaultFee := std.NewFee(50000, std.MustParseCoin(ugnot.ValueString(1000000))) + + meta, err := loader.LoadPackages(privKey, defaultFee, nil) + require.NoError(t, err) + return meta +} diff --git a/gno.land/pkg/gnoland/app.go b/gno.land/pkg/gnoland/app.go index e0c93f6194f..0826071b9f5 100644 --- a/gno.land/pkg/gnoland/app.go +++ b/gno.land/pkg/gnoland/app.go @@ -34,11 +34,13 @@ import ( // AppOptions contains the options to create the gno.land ABCI application. type AppOptions struct { - DB dbm.DB // required - Logger *slog.Logger // required - EventSwitch events.EventSwitch // required - VMOutput io.Writer // optional - InitChainerConfig // options related to InitChainer + DB dbm.DB // required + Logger *slog.Logger // required + EventSwitch events.EventSwitch // required + VMOutput io.Writer // optional + SkipGenesisVerification bool // default to verify genesis transactions + InitChainerConfig // options related to InitChainer + MinGasPrices string // optional } // TestAppOptions provides a "ready" default [AppOptions] for use with @@ -53,6 +55,7 @@ func TestAppOptions(db dbm.DB) *AppOptions { StdlibDir: filepath.Join(gnoenv.RootDir(), "gnovm", "stdlibs"), CacheStdlibLoad: true, }, + SkipGenesisVerification: true, } } @@ -79,9 +82,13 @@ func NewAppWithOptions(cfg *AppOptions) (abci.Application, error) { mainKey := store.NewStoreKey("main") baseKey := store.NewStoreKey("base") + // set sdk app options + var appOpts []func(*sdk.BaseApp) + if cfg.MinGasPrices != "" { + appOpts = append(appOpts, sdk.SetMinGasPrices(cfg.MinGasPrices)) + } // Create BaseApp. - // TODO: Add a consensus based min gas prices for the node, by default it does not check - baseApp := sdk.NewBaseApp("gnoland", cfg.Logger, cfg.DB, baseKey, mainKey) + baseApp := sdk.NewBaseApp("gnoland", cfg.Logger, cfg.DB, baseKey, mainKey, appOpts...) baseApp.SetAppVersion("dev") // Set mounts for BaseApp's MultiStore. @@ -89,21 +96,23 @@ func NewAppWithOptions(cfg *AppOptions) (abci.Application, error) { baseApp.MountStoreWithDB(baseKey, dbadapter.StoreConstructor, cfg.DB) // Construct keepers. - acctKpr := auth.NewAccountKeeper(mainKey, ProtoGnoAccount) - bankKpr := bank.NewBankKeeper(acctKpr) paramsKpr := params.NewParamsKeeper(mainKey, "vm") + acctKpr := auth.NewAccountKeeper(mainKey, paramsKpr, ProtoGnoAccount) + gpKpr := auth.NewGasPriceKeeper(mainKey) + bankKpr := bank.NewBankKeeper(acctKpr) + vmk := vm.NewVMKeeper(baseKey, mainKey, acctKpr, bankKpr, paramsKpr) vmk.Output = cfg.VMOutput // Set InitChainer icc := cfg.InitChainerConfig icc.baseApp = baseApp - icc.acctKpr, icc.bankKpr, icc.vmKpr, icc.paramsKpr = acctKpr, bankKpr, vmk, paramsKpr + icc.acctKpr, icc.bankKpr, icc.vmKpr, icc.paramsKpr, icc.gpKpr = acctKpr, bankKpr, vmk, paramsKpr, gpKpr baseApp.SetInitChainer(icc.InitChainer) // Set AnteHandler authOptions := auth.AnteOptions{ - VerifyGenesisSignatures: false, // for development + VerifyGenesisSignatures: !cfg.SkipGenesisVerification, } authAnteHandler := auth.NewAnteHandler( acctKpr, bankKpr, auth.DefaultSigVerificationGasConsumer, authOptions) @@ -112,9 +121,11 @@ func NewAppWithOptions(cfg *AppOptions) (abci.Application, error) { func(ctx sdk.Context, tx std.Tx, simulate bool) ( newCtx sdk.Context, res sdk.Result, abort bool, ) { + // Add last gas price in the context + ctx = ctx.WithValue(auth.GasPriceContextKey{}, gpKpr.LastGasPrice(ctx)) + // Override auth params. - ctx = ctx. - WithValue(auth.AuthParamsContextKey{}, auth.DefaultParams()) + ctx = ctx.WithValue(auth.AuthParamsContextKey{}, acctKpr.GetParams(ctx)) // Continue on with default auth ante handler. newCtx, res, abort = authAnteHandler(ctx, tx, simulate) return @@ -145,6 +156,8 @@ func NewAppWithOptions(cfg *AppOptions) (abci.Application, error) { baseApp.SetEndBlocker( EndBlocker( c, + acctKpr, + gpKpr, vmk, baseApp, ), @@ -169,12 +182,28 @@ func NewAppWithOptions(cfg *AppOptions) (abci.Application, error) { return baseApp, nil } +// GenesisAppConfig wraps the most important +// genesis params relating to the App +type GenesisAppConfig struct { + SkipFailingTxs bool // does not stop the chain from starting if any tx fails + SkipSigVerification bool // does not verify the transaction signatures in genesis +} + +// NewTestGenesisAppConfig returns a testing genesis app config +func NewTestGenesisAppConfig() GenesisAppConfig { + return GenesisAppConfig{ + SkipFailingTxs: true, + SkipSigVerification: true, + } +} + // NewApp creates the gno.land application. func NewApp( dataRootDir string, - skipFailingGenesisTxs bool, + genesisCfg GenesisAppConfig, evsw events.EventSwitch, logger *slog.Logger, + minGasPrices string, ) (abci.Application, error) { var err error @@ -185,8 +214,10 @@ func NewApp( GenesisTxResultHandler: PanicOnFailingTxResultHandler, StdlibDir: filepath.Join(gnoenv.RootDir(), "gnovm", "stdlibs"), }, + MinGasPrices: minGasPrices, + SkipGenesisVerification: genesisCfg.SkipSigVerification, } - if skipFailingGenesisTxs { + if genesisCfg.SkipFailingTxs { cfg.GenesisTxResultHandler = NoopGenesisTxResultHandler } @@ -236,6 +267,7 @@ type InitChainerConfig struct { acctKpr auth.AccountKeeperI bankKpr bank.BankKeeperI paramsKpr params.ParamsKeeperI + gpKpr auth.GasPriceKeeperI } // InitChainer is the function that can be used as a [sdk.InitChainer]. @@ -293,6 +325,10 @@ func (cfg InitChainerConfig) loadAppState(ctx sdk.Context, appState any) ([]abci if !ok { return nil, fmt.Errorf("invalid AppState of type %T", appState) } + cfg.acctKpr.InitGenesis(ctx, state.Auth) + params := cfg.acctKpr.GetParams(ctx) + ctx = ctx.WithValue(auth.AuthParamsContextKey{}, params) + auth.InitChainer(ctx, cfg.gpKpr.(auth.GasPriceKeeper), params.InitialGasPrice) // Apply genesis balances. for _, bal := range state.Balances { @@ -370,6 +406,8 @@ type endBlockerApp interface { // validator set changes func EndBlocker( collector *collector[validatorUpdate], + acctKpr auth.AccountKeeperI, + gpKpr auth.GasPriceKeeperI, vmk vm.VMKeeperI, app endBlockerApp, ) func( @@ -377,6 +415,14 @@ func EndBlocker( req abci.RequestEndBlock, ) abci.ResponseEndBlock { return func(ctx sdk.Context, _ abci.RequestEndBlock) abci.ResponseEndBlock { + // set the auth params value in the ctx. The EndBlocker will use InitialGasPrice in + // the params to calculate the updated gas price. + if acctKpr != nil { + ctx = ctx.WithValue(auth.AuthParamsContextKey{}, acctKpr.GetParams(ctx)) + } + if acctKpr != nil && gpKpr != nil { + auth.EndBlocker(ctx, gpKpr) + } // Check if there was a valset change if len(collector.getEvents()) == 0 { // No valset updates diff --git a/gno.land/pkg/gnoland/app_test.go b/gno.land/pkg/gnoland/app_test.go index 999e04b2c4b..361d7505157 100644 --- a/gno.land/pkg/gnoland/app_test.go +++ b/gno.land/pkg/gnoland/app_test.go @@ -19,6 +19,10 @@ import ( "github.com/gnolang/gno/tm2/pkg/events" "github.com/gnolang/gno/tm2/pkg/log" "github.com/gnolang/gno/tm2/pkg/sdk" + "github.com/gnolang/gno/tm2/pkg/sdk/auth" + "github.com/gnolang/gno/tm2/pkg/sdk/bank" + "github.com/gnolang/gno/tm2/pkg/sdk/params" + "github.com/gnolang/gno/tm2/pkg/sdk/testutils" "github.com/gnolang/gno/tm2/pkg/std" "github.com/gnolang/gno/tm2/pkg/store" "github.com/gnolang/gno/tm2/pkg/store/dbadapter" @@ -38,6 +42,36 @@ func TestNewAppWithOptions(t *testing.T) { assert.Equal(t, "gnoland", bapp.Name()) addr := crypto.AddressFromPreimage([]byte("test1")) + + appState := DefaultGenState() + appState.Balances = []Balance{ + { + Address: addr, + Amount: []std.Coin{{Amount: 1e15, Denom: "ugnot"}}, + }, + } + appState.Txs = []TxWithMetadata{ + { + Tx: std.Tx{ + Msgs: []std.Msg{vm.NewMsgAddPackage(addr, "gno.land/r/demo", []*gnovm.MemFile{ + { + Name: "demo.gno", + Body: "package demo; func Hello() string { return `hello`; }", + }, + })}, + Fee: std.Fee{GasWanted: 1e6, GasFee: std.Coin{Amount: 1e6, Denom: "ugnot"}}, + Signatures: []std.Signature{{}}, // one empty signature + }, + }, + } + appState.Params = []Param{ + {key: "foo", kind: "string", value: "hello"}, + {key: "foo", kind: "int64", value: int64(-42)}, + {key: "foo", kind: "uint64", value: uint64(1337)}, + {key: "foo", kind: "bool", value: true}, + {key: "foo", kind: "bytes", value: []byte{0x48, 0x69, 0x21}}, + } + resp := bapp.InitChain(abci.RequestInitChain{ Time: time.Now(), ChainID: "dev", @@ -45,35 +79,7 @@ func TestNewAppWithOptions(t *testing.T) { Block: defaultBlockParams(), }, Validators: []abci.ValidatorUpdate{}, - AppState: GnoGenesisState{ - Balances: []Balance{ - { - Address: addr, - Amount: []std.Coin{{Amount: 1e15, Denom: "ugnot"}}, - }, - }, - Txs: []TxWithMetadata{ - { - Tx: std.Tx{ - Msgs: []std.Msg{vm.NewMsgAddPackage(addr, "gno.land/r/demo", []*gnovm.MemFile{ - { - Name: "demo.gno", - Body: "package demo; func Hello() string { return `hello`; }", - }, - })}, - Fee: std.Fee{GasWanted: 1e6, GasFee: std.Coin{Amount: 1e6, Denom: "ugnot"}}, - Signatures: []std.Signature{{}}, // one empty signature - }, - }, - }, - Params: []Param{ - {key: "foo", kind: "string", value: "hello"}, - {key: "foo", kind: "int64", value: int64(-42)}, - {key: "foo", kind: "uint64", value: uint64(1337)}, - {key: "foo", kind: "bool", value: true}, - {key: "foo", kind: "bytes", value: []byte{0x48, 0x69, 0x21}}, - }, - }, + AppState: appState, }) require.True(t, resp.IsOK(), "InitChain response: %v", resp) @@ -128,7 +134,7 @@ func TestNewApp(t *testing.T) { // NewApp should have good defaults and manage to run InitChain. td := t.TempDir() - app, err := NewApp(td, true, events.NewEventSwitch(), log.NewNoopLogger()) + app, err := NewApp(td, NewTestGenesisAppConfig(), events.NewEventSwitch(), log.NewNoopLogger(), "") require.NoError(t, err, "NewApp should be successful") resp := app.InitChain(abci.RequestInitChain{ @@ -142,7 +148,7 @@ func TestNewApp(t *testing.T) { }, }, Validators: []abci.ValidatorUpdate{}, - AppState: GnoGenesisState{}, + AppState: DefaultGenState(), }) assert.True(t, resp.IsOK(), "resp is not OK: %v", resp) } @@ -212,8 +218,12 @@ func testInitChainerLoadStdlib(t *testing.T, cached bool) { //nolint:thelper vmKpr: mock, CacheStdlibLoad: cached, } + // Construct keepers. + paramsKpr := params.NewParamsKeeper(iavlCapKey, "") + cfg.acctKpr = auth.NewAccountKeeper(iavlCapKey, paramsKpr, ProtoGnoAccount) + cfg.gpKpr = auth.NewGasPriceKeeper(iavlCapKey) cfg.InitChainer(testCtx, abci.RequestInitChain{ - AppState: GnoGenesisState{}, + AppState: DefaultGenState(), }) // assert number of calls @@ -485,7 +495,7 @@ func TestEndBlocker(t *testing.T) { c := newCollector[validatorUpdate](&mockEventSwitch{}, noFilter) // Create the EndBlocker - eb := EndBlocker(c, nil, &mockEndBlockerApp{}) + eb := EndBlocker(c, nil, nil, nil, &mockEndBlockerApp{}) // Run the EndBlocker res := eb(sdk.Context{}, abci.RequestEndBlock{}) @@ -525,7 +535,7 @@ func TestEndBlocker(t *testing.T) { mockEventSwitch.FireEvent(gnostdlibs.GnoEvent{}) // Create the EndBlocker - eb := EndBlocker(c, mockVMKeeper, &mockEndBlockerApp{}) + eb := EndBlocker(c, nil, nil, mockVMKeeper, &mockEndBlockerApp{}) // Run the EndBlocker res := eb(sdk.Context{}, abci.RequestEndBlock{}) @@ -568,7 +578,7 @@ func TestEndBlocker(t *testing.T) { mockEventSwitch.FireEvent(gnostdlibs.GnoEvent{}) // Create the EndBlocker - eb := EndBlocker(c, mockVMKeeper, &mockEndBlockerApp{}) + eb := EndBlocker(c, nil, nil, mockVMKeeper, &mockEndBlockerApp{}) // Run the EndBlocker res := eb(sdk.Context{}, abci.RequestEndBlock{}) @@ -636,7 +646,7 @@ func TestEndBlocker(t *testing.T) { mockEventSwitch.FireEvent(txEvent) // Create the EndBlocker - eb := EndBlocker(c, mockVMKeeper, &mockEndBlockerApp{}) + eb := EndBlocker(c, nil, nil, mockVMKeeper, &mockEndBlockerApp{}) // Run the EndBlocker res := eb(sdk.Context{}, abci.RequestEndBlock{}) @@ -651,3 +661,338 @@ func TestEndBlocker(t *testing.T) { } }) } + +func TestGasPriceUpdate(t *testing.T) { + app := newGasPriceTestApp(t) + + // with default initial gas price 0.1 ugnot per gas + gnoGen := gnoGenesisState(t) + + // abci inintChain + app.InitChain(abci.RequestInitChain{ + AppState: gnoGen, + ChainID: "test-chain", + ConsensusParams: &abci.ConsensusParams{ + Block: &abci.BlockParams{ + MaxGas: 10000, + }, + }, + }) + baseApp := app.(*sdk.BaseApp) + require.Equal(t, int64(0), baseApp.LastBlockHeight()) + // Case 1 + // CheckTx failed because the GasFee is less than the initial gas price. + + tx := newCounterTx(100) + tx.Fee = std.Fee{ + GasWanted: 100, + GasFee: sdk.Coin{ + Amount: 9, + Denom: "ugnot", + }, + } + txBytes, err := amino.Marshal(tx) + require.NoError(t, err) + r := app.CheckTx(abci.RequestCheckTx{Tx: txBytes}) + assert.False(t, r.IsOK(), fmt.Sprintf("%v", r)) + + // Case 2: + // A previously successful CheckTx failed after the block gas price increased. + // Check Tx Ok + tx2 := newCounterTx(100) + tx2.Fee = std.Fee{ + GasWanted: 1000, + GasFee: sdk.Coin{ + Amount: 100, + Denom: "ugnot", + }, + } + txBytes2, err := amino.Marshal(tx2) + require.NoError(t, err) + r = app.CheckTx(abci.RequestCheckTx{Tx: txBytes2}) + assert.True(t, r.IsOK(), fmt.Sprintf("%v", r)) + + // After replaying a block, the gas price increased. + header := &bft.Header{ChainID: "test-chain", Height: 1} + app.BeginBlock(abci.RequestBeginBlock{Header: header}) + // Delvier Tx consumes more than that target block gas 6000. + + tx6001 := newCounterTx(6001) + tx6001.Fee = std.Fee{ + GasWanted: 20000, + GasFee: sdk.Coin{ + Amount: 200, + Denom: "ugnot", + }, + } + txBytes6001, err := amino.Marshal(tx6001) + require.NoError(t, err) + res := app.DeliverTx(abci.RequestDeliverTx{Tx: txBytes6001}) + require.True(t, res.IsOK(), fmt.Sprintf("%v", res)) + app.EndBlock(abci.RequestEndBlock{}) + app.Commit() + + // CheckTx failed because gas price increased + r = app.CheckTx(abci.RequestCheckTx{Tx: txBytes2}) + assert.False(t, r.IsOK(), fmt.Sprintf("%v", r)) + + // Case 3: + // A previously failed CheckTx successed after block gas price reduced. + + // CheckTx Failed + r = app.CheckTx(abci.RequestCheckTx{Tx: txBytes2}) + assert.False(t, r.IsOK(), fmt.Sprintf("%v", r)) + // Replayed a Block, the gas price decrease + header = &bft.Header{ChainID: "test-chain", Height: 2} + app.BeginBlock(abci.RequestBeginBlock{Header: header}) + // Delvier Tx consumes less than that target block gas 6000. + + tx200 := newCounterTx(200) + tx200.Fee = std.Fee{ + GasWanted: 20000, + GasFee: sdk.Coin{ + Amount: 200, + Denom: "ugnot", + }, + } + txBytes200, err := amino.Marshal(tx200) + require.NoError(t, err) + + res = app.DeliverTx(abci.RequestDeliverTx{Tx: txBytes200}) + require.True(t, res.IsOK(), fmt.Sprintf("%v", res)) + + app.EndBlock(abci.RequestEndBlock{}) + app.Commit() + + // CheckTx earlier failed tx, now is OK + r = app.CheckTx(abci.RequestCheckTx{Tx: txBytes2}) + assert.True(t, r.IsOK(), fmt.Sprintf("%v", r)) + + // Case 4 + // require matching expected GasPrice after three blocks ( increase case) + replayBlock(t, baseApp, 8000, 3) + replayBlock(t, baseApp, 8000, 4) + replayBlock(t, baseApp, 6000, 5) + + key := []byte("gasPrice") + query := abci.RequestQuery{ + Path: ".store/main/key", + Data: key, + } + qr := app.Query(query) + var gp std.GasPrice + err = amino.Unmarshal(qr.Value, &gp) + require.NoError(t, err) + require.Equal(t, "108ugnot", gp.Price.String()) + + // Case 5, + // require matching expected GasPrice after low gas blocks ( decrease below initial gas price case) + + replayBlock(t, baseApp, 5000, 6) + replayBlock(t, baseApp, 5000, 7) + replayBlock(t, baseApp, 5000, 8) + + qr = app.Query(query) + err = amino.Unmarshal(qr.Value, &gp) + require.NoError(t, err) + require.Equal(t, "102ugnot", gp.Price.String()) + + replayBlock(t, baseApp, 5000, 9) + + qr = app.Query(query) + err = amino.Unmarshal(qr.Value, &gp) + require.NoError(t, err) + require.Equal(t, "100ugnot", gp.Price.String()) +} + +func newGasPriceTestApp(t *testing.T) abci.Application { + t.Helper() + cfg := TestAppOptions(memdb.NewMemDB()) + cfg.EventSwitch = events.NewEventSwitch() + + // Capabilities keys. + mainKey := store.NewStoreKey("main") + baseKey := store.NewStoreKey("base") + + baseApp := sdk.NewBaseApp("gnoland", cfg.Logger, cfg.DB, baseKey, mainKey) + baseApp.SetAppVersion("test") + + // Set mounts for BaseApp's MultiStore. + baseApp.MountStoreWithDB(mainKey, iavl.StoreConstructor, cfg.DB) + baseApp.MountStoreWithDB(baseKey, dbadapter.StoreConstructor, cfg.DB) + + // Construct keepers. + paramsKpr := params.NewParamsKeeper(mainKey, "") + acctKpr := auth.NewAccountKeeper(mainKey, paramsKpr, ProtoGnoAccount) + gpKpr := auth.NewGasPriceKeeper(mainKey) + bankKpr := bank.NewBankKeeper(acctKpr) + vmk := vm.NewVMKeeper(baseKey, mainKey, acctKpr, bankKpr, paramsKpr) + + // Set InitChainer + icc := cfg.InitChainerConfig + icc.baseApp = baseApp + icc.acctKpr, icc.bankKpr, icc.vmKpr, icc.gpKpr = acctKpr, bankKpr, vmk, gpKpr + baseApp.SetInitChainer(icc.InitChainer) + + // Set AnteHandler + baseApp.SetAnteHandler( + // Override default AnteHandler with custom logic. + func(ctx sdk.Context, tx std.Tx, simulate bool) ( + newCtx sdk.Context, res sdk.Result, abort bool, + ) { + // Add last gas price in the context + ctx = ctx.WithValue(auth.GasPriceContextKey{}, gpKpr.LastGasPrice(ctx)) + + // Override auth params. + ctx = ctx.WithValue(auth.AuthParamsContextKey{}, acctKpr.GetParams(ctx)) + // Continue on with default auth ante handler. + if ctx.IsCheckTx() { + res := auth.EnsureSufficientMempoolFees(ctx, tx.Fee) + if !res.IsOK() { + return ctx, res, true + } + } + + newCtx = auth.SetGasMeter(false, ctx, tx.Fee.GasWanted) + + count := getTotalCount(tx) + + newCtx.GasMeter().ConsumeGas(count, "counter-ante") + res = sdk.Result{ + GasWanted: getTotalCount(tx), + } + return + }, + ) + + // Set up the event collector + c := newCollector[validatorUpdate]( + cfg.EventSwitch, // global event switch filled by the node + validatorEventFilter, // filter fn that keeps the collector valid + ) + + // Set EndBlocker + baseApp.SetEndBlocker( + EndBlocker( + c, + acctKpr, + gpKpr, + nil, + baseApp, + ), + ) + + // Set a handler Route. + baseApp.Router().AddRoute("auth", auth.NewHandler(acctKpr)) + baseApp.Router().AddRoute("bank", bank.NewHandler(bankKpr)) + baseApp.Router().AddRoute( + testutils.RouteMsgCounter, + newTestHandler( + func(ctx sdk.Context, msg sdk.Msg) sdk.Result { return sdk.Result{} }, + ), + ) + + baseApp.Router().AddRoute("vm", vm.NewHandler(vmk)) + + // Load latest version. + if err := baseApp.LoadLatestVersion(); err != nil { + t.Fatalf("failed to load the lastest state: %v", err) + } + + // Initialize the VMKeeper. + ms := baseApp.GetCacheMultiStore() + vmk.Initialize(cfg.Logger, ms) + ms.MultiWrite() // XXX why was't this needed? + + return baseApp +} + +// newTx constructs a tx with multiple counter messages. +// we can use the counter as the gas used for the message. + +func newCounterTx(counters ...int64) sdk.Tx { + msgs := make([]sdk.Msg, len(counters)) + + for i, c := range counters { + msgs[i] = testutils.MsgCounter{Counter: c} + } + tx := sdk.Tx{Msgs: msgs} + return tx +} + +func getTotalCount(tx sdk.Tx) int64 { + var c int64 + for _, m := range tx.Msgs { + c = +m.(testutils.MsgCounter).Counter + } + return c +} + +func gnoGenesisState(t *testing.T) GnoGenesisState { + t.Helper() + gen := GnoGenesisState{} + genBytes := []byte(`{ + "@type": "/gno.GenesisState", + "auth": { + "params": { + "gas_price_change_compressor": "8", + "initial_gasprice": { + "gas": "1000", + "price": "100ugnot" + }, + "max_memo_bytes": "65536", + "sig_verify_cost_ed25519": "590", + "sig_verify_cost_secp256k1": "1000", + "target_gas_ratio": "60", + "tx_sig_limit": "7", + "tx_size_cost_per_byte": "10" + } + } + }`) + err := amino.UnmarshalJSON(genBytes, &gen) + if err != nil { + t.Fatalf("failed to create genesis state: %v", err) + } + return gen +} + +func replayBlock(t *testing.T, app *sdk.BaseApp, gas int64, hight int64) { + t.Helper() + tx := newCounterTx(gas) + tx.Fee = std.Fee{ + GasWanted: 20000, + GasFee: sdk.Coin{ + Amount: 1000, + Denom: "ugnot", + }, + } + txBytes, err := amino.Marshal(tx) + require.NoError(t, err) + + header := &bft.Header{ChainID: "test-chain", Height: hight} + app.BeginBlock(abci.RequestBeginBlock{Header: header}) + // consume gas in the block + res := app.DeliverTx(abci.RequestDeliverTx{Tx: txBytes}) + require.True(t, res.IsOK(), fmt.Sprintf("%v", res)) + app.EndBlock(abci.RequestEndBlock{}) + app.Commit() +} + +type testHandler struct { + process func(sdk.Context, sdk.Msg) sdk.Result + query func(sdk.Context, abci.RequestQuery) abci.ResponseQuery +} + +func (th testHandler) Process(ctx sdk.Context, msg sdk.Msg) sdk.Result { + return th.process(ctx, msg) +} + +func (th testHandler) Query(ctx sdk.Context, req abci.RequestQuery) abci.ResponseQuery { + return th.query(ctx, req) +} + +func newTestHandler(proc func(sdk.Context, sdk.Msg) sdk.Result) sdk.Handler { + return testHandler{ + process: proc, + } +} diff --git a/gno.land/pkg/gnoland/genesis.go b/gno.land/pkg/gnoland/genesis.go index 778121d59ed..a754e7a4644 100644 --- a/gno.land/pkg/gnoland/genesis.go +++ b/gno.land/pkg/gnoland/genesis.go @@ -12,12 +12,15 @@ import ( bft "github.com/gnolang/gno/tm2/pkg/bft/types" "github.com/gnolang/gno/tm2/pkg/crypto" osm "github.com/gnolang/gno/tm2/pkg/os" + "github.com/gnolang/gno/tm2/pkg/sdk/auth" "github.com/gnolang/gno/tm2/pkg/std" "github.com/pelletier/go-toml" ) +const initGasPrice = "1ugnot/1000gas" + // LoadGenesisBalancesFile loads genesis balances from the provided file path. -func LoadGenesisBalancesFile(path string) ([]Balance, error) { +func LoadGenesisBalancesFile(path string) (Balances, error) { // each balance is in the form: g1xxxxxxxxxxxxxxxx=100000ugnot content, err := osm.ReadFile(path) if err != nil { @@ -25,7 +28,7 @@ func LoadGenesisBalancesFile(path string) ([]Balance, error) { } lines := strings.Split(string(content), "\n") - balances := make([]Balance, 0, len(lines)) + balances := make(Balances, len(lines)) for _, line := range lines { line = strings.TrimSpace(line) @@ -53,10 +56,7 @@ func LoadGenesisBalancesFile(path string) ([]Balance, error) { return nil, fmt.Errorf("invalid balance coins %s: %w", parts[1], err) } - balances = append(balances, Balance{ - Address: addr, - Amount: coins, - }) + balances.Set(addr, coins) } return balances, nil @@ -187,3 +187,20 @@ func LoadPackage(pkg gnomod.Pkg, creator bft.Address, fee std.Fee, deposit std.C return tx, nil } + +func DefaultGenState() GnoGenesisState { + authGen := auth.DefaultGenesisState() + gp, err := std.ParseGasPrice(initGasPrice) + if err != nil { + panic(err) + } + authGen.Params.InitialGasPrice = gp + + gs := GnoGenesisState{ + Balances: []Balance{}, + Txs: []TxWithMetadata{}, + Auth: authGen, + } + + return gs +} diff --git a/gno.land/pkg/gnoland/node_inmemory.go b/gno.land/pkg/gnoland/node_inmemory.go index f42166411c8..cc9e74a78d8 100644 --- a/gno.land/pkg/gnoland/node_inmemory.go +++ b/gno.land/pkg/gnoland/node_inmemory.go @@ -16,15 +16,16 @@ import ( "github.com/gnolang/gno/tm2/pkg/db" "github.com/gnolang/gno/tm2/pkg/db/memdb" "github.com/gnolang/gno/tm2/pkg/events" - "github.com/gnolang/gno/tm2/pkg/p2p" + "github.com/gnolang/gno/tm2/pkg/p2p/types" ) type InMemoryNodeConfig struct { - PrivValidator bft.PrivValidator // identity of the validator - Genesis *bft.GenesisDoc - TMConfig *tmcfg.Config - DB *memdb.MemDB // will be initialized if nil - VMOutput io.Writer // optional + PrivValidator bft.PrivValidator // identity of the validator + Genesis *bft.GenesisDoc + TMConfig *tmcfg.Config + DB db.DB // will be initialized if nil + VMOutput io.Writer // optional + SkipGenesisVerification bool // If StdlibDir not set, then it's filepath.Join(TMConfig.RootDir, "gnovm", "stdlibs") InitChainerConfig @@ -112,11 +113,12 @@ func NewInMemoryNode(logger *slog.Logger, cfg *InMemoryNodeConfig) (*node.Node, // Initialize the application with the provided options gnoApp, err := NewAppWithOptions(&AppOptions{ - Logger: logger, - DB: cfg.DB, - EventSwitch: evsw, - InitChainerConfig: cfg.InitChainerConfig, - VMOutput: cfg.VMOutput, + Logger: logger, + DB: cfg.DB, + EventSwitch: evsw, + InitChainerConfig: cfg.InitChainerConfig, + VMOutput: cfg.VMOutput, + SkipGenesisVerification: cfg.SkipGenesisVerification, }) if err != nil { return nil, fmt.Errorf("error initializing new app: %w", err) @@ -138,7 +140,7 @@ func NewInMemoryNode(logger *slog.Logger, cfg *InMemoryNodeConfig) (*node.Node, dbProvider := func(*node.DBContext) (db.DB, error) { return cfg.DB, nil } // Generate p2p node identity - nodekey := &p2p.NodeKey{PrivKey: ed25519.GenPrivKey()} + nodekey := &types.NodeKey{PrivKey: ed25519.GenPrivKey()} // Create and return the in-memory node instance return node.NewNode(cfg.TMConfig, diff --git a/gno.land/pkg/gnoland/types.go b/gno.land/pkg/gnoland/types.go index a5f76fdcef7..66fb2f54e8a 100644 --- a/gno.land/pkg/gnoland/types.go +++ b/gno.land/pkg/gnoland/types.go @@ -8,6 +8,8 @@ import ( "os" "github.com/gnolang/gno/tm2/pkg/amino" + "github.com/gnolang/gno/tm2/pkg/crypto" + "github.com/gnolang/gno/tm2/pkg/sdk/auth" "github.com/gnolang/gno/tm2/pkg/std" ) @@ -25,9 +27,10 @@ func ProtoGnoAccount() std.Account { } type GnoGenesisState struct { - Balances []Balance `json:"balances"` - Txs []TxWithMetadata `json:"txs"` - Params []Param `json:"params"` + Balances []Balance `json:"balances"` + Txs []TxWithMetadata `json:"txs"` + Params []Param `json:"params"` + Auth auth.GenesisState `json:"auth"` } type TxWithMetadata struct { @@ -84,3 +87,31 @@ func ReadGenesisTxs(ctx context.Context, path string) ([]TxWithMetadata, error) return txs, nil } + +// SignGenesisTxs will sign all txs passed as argument using the private key. +// This signature is only valid for genesis transactions as the account number and sequence are 0 +func SignGenesisTxs(txs []TxWithMetadata, privKey crypto.PrivKey, chainID string) error { + for index, tx := range txs { + // Upon verifying genesis transactions, the account number and sequence are considered to be 0. + // The reason for this is that it is not possible to know the account number (or sequence!) in advance + // when generating the genesis transaction signature + bytes, err := tx.Tx.GetSignBytes(chainID, 0, 0) + if err != nil { + return fmt.Errorf("unable to get sign bytes for transaction, %w", err) + } + + signature, err := privKey.Sign(bytes) + if err != nil { + return fmt.Errorf("unable to sign genesis transaction, %w", err) + } + + txs[index].Tx.Signatures = []std.Signature{ + { + PubKey: privKey.PubKey(), + Signature: signature, + }, + } + } + + return nil +} diff --git a/gno.land/pkg/gnoland/types_test.go b/gno.land/pkg/gnoland/types_test.go index b4625d6d7d6..c501325bc3e 100644 --- a/gno.land/pkg/gnoland/types_test.go +++ b/gno.land/pkg/gnoland/types_test.go @@ -11,6 +11,7 @@ import ( "github.com/gnolang/gno/gno.land/pkg/gnoland/ugnot" "github.com/gnolang/gno/tm2/pkg/amino" "github.com/gnolang/gno/tm2/pkg/crypto" + "github.com/gnolang/gno/tm2/pkg/crypto/secp256k1" "github.com/gnolang/gno/tm2/pkg/sdk/bank" "github.com/gnolang/gno/tm2/pkg/std" "github.com/stretchr/testify/assert" @@ -129,3 +130,29 @@ func TestReadGenesisTxs(t *testing.T) { } }) } + +func TestSignGenesisTx(t *testing.T) { + t.Parallel() + + var ( + txs = generateTxs(t, 100) + privKey = secp256k1.GenPrivKey() + pubKey = privKey.PubKey() + chainID = "testing" + ) + + // Make sure the transactions are properly signed + require.NoError(t, SignGenesisTxs(txs, privKey, chainID)) + + // Make sure the signatures are valid + for _, tx := range txs { + payload, err := tx.Tx.GetSignBytes(chainID, 0, 0) + require.NoError(t, err) + + sigs := tx.Tx.GetSignatures() + require.Len(t, sigs, 1) + + assert.True(t, pubKey.Equals(sigs[0].PubKey)) + assert.True(t, pubKey.VerifyBytes(payload, sigs[0].Signature)) + } +} diff --git a/gno.land/pkg/gnoweb/.gitignore b/gno.land/pkg/gnoweb/.gitignore new file mode 100644 index 00000000000..dd09eb49099 --- /dev/null +++ b/gno.land/pkg/gnoweb/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +tmp/ +.cache diff --git a/gno.land/pkg/gnoweb/Makefile b/gno.land/pkg/gnoweb/Makefile new file mode 100644 index 00000000000..8e8b6bf1a2c --- /dev/null +++ b/gno.land/pkg/gnoweb/Makefile @@ -0,0 +1,104 @@ +# Configurable arguments +DEV_REMOTE ?= 127.0.0.1:26657 +CHAIN_ID ?= test3 +PUBLIC_DIR ?= public + +# Variable Declarations +tools_run := go run -modfile ./tools/go.mod +run_reflex := $(tools_run) github.com/cespare/reflex +run_logname := go -C ./tools run ./cmd/logname + +# css config +input_css := frontend/css/input.css +output_css := $(PUBLIC_DIR)/styles.css +tw_version := 3.4.14 +tw_config_path := frontend/css/tx.config.js +templates_files := $(shell find . -iname '*.gohtml') + +# static config +src_dir_static := frontend/static +out_dir_static := $(PUBLIC_DIR) +input_static := $(shell find $(src_dir_static) -type f) +output_static := $(patsubst $(src_dir_static)/%, $(out_dir_static)/%, $(input_static)) + +# esbuild config +src_dir_js := frontend/js +out_dir_js := $(PUBLIC_DIR)/js +input_js := $(shell find $(src_dir_js) -name '*.ts') +output_js := $(patsubst $(src_dir_js)/%.ts,$(out_dir_js)/%.js,$(input_js)) +esbuild_version := 0.24.0 + +# cache +cache_dir := .cache + +############# +# Targets +############# +.PHONY: all generate fmt css ts + +# Install dependencies +all: generate + +test: + go test -v ./... + +# Generate process +generate: css ts static + +css: $(output_css) +$(output_css): $(input_css) $(templates_files) + npx -y tailwindcss@$(tw_version) -c $(tw_config_path) -i $(input_css) -o $@ --minify # tailwind + touch $@ + +ts: $(output_js) +$(out_dir_js)/%.js: $(src_dir_js)/%.ts + npx -y esbuild $< --log-level=error --bundle --outdir=$(out_dir_js) --format=esm --minify + +# Rule to copy static files while preserving directory structure +static: $(output_static) +$(out_dir_static)/%: $(src_dir_static)/% + @mkdir -p $(dir $@) + @cp -v $< $@ + +# Format process +fmt: + go fmt ./... + + ############################### + # Developments + ############################### +.PHONY: dev dev.server dev.css dev.ts deps + +# Run the development dependencies in parallel +dev: + @echo "-- starting development tools" + @PUBLIC_DIR=$(cache_dir)/public $(MAKE) -j 3 \ + dev.gnoweb \ + dev.ts \ + dev.css + +# Go server in development mode +dev.gnoweb: generate + $(run_reflex) -s -r '.*\.go(html)?' -- \ + go run ../../cmd/gnoweb -assets-dir=${PUBLIC_DIR} -chainid=${CHAIN_ID} -remote=${DEV_REMOTE} \ + 2>&1 | $(run_logname) gnoweb + +# Tailwind CSS in development mode +dev.css: generate | $(PUBLIC_DIR) + npx -y tailwindcss@$(tw_version) -c $(tw_config_path) --verbose -i $(input_css) -o $(output_css) --watch \ + 2>&1 | $(run_logname) tailwind + +# XXX: add versioning on esbuild +# TS in development mode +dev.ts: generate | $(PUBLIC_DIR) + npx -y esbuild@$(esbuild_version) $(input_js) --bundle --outdir=$(out_dir_js) --sourcemap --format=esm --watch \ + 2>&1 | $(run_logname) esbuild + +# Cleanup +clean: + rm -rf $(cache_dir) tmp +fclean: clean + rm -rf $(PUBLIC_DIR) + +# Dirs +$(PUBLIC_DIR):; mkdir -p $@ diff --git a/gno.land/pkg/gnoweb/README.md b/gno.land/pkg/gnoweb/README.md new file mode 100644 index 00000000000..287279538d8 --- /dev/null +++ b/gno.land/pkg/gnoweb/README.md @@ -0,0 +1,45 @@ +# gnoweb + +`gnoweb` is a universal web frontend for the gno.land blockchain. + +This README provides instructions on how to set up and run `gnoweb` for development purposes. + +## Prerequisites + +Before you begin, ensure you have the following software installed on your machine: + +- **Node.js**: Required for running JavaScript and CSS build tools. +- **Go**: Required for building `gnoweb` + +## Development + +To start the development environment, which runs multiple development tools in parallel, +use the following command: + +```sh +make dev +``` + +This will: + +- Start a Go server in development mode and watch for any Go files change (targeting [localhost](http://localhost:8888)). +- Enable Tailwind CSS in watch mode to automatically compile CSS changes. +- Use esbuild in watch mode to automatically transpile and bundle TypeScript changes. + +You can customize the behavior of the Go server using the `DEV_REMOTE` and +`CHAIN_ID` environment variables. For example, to use `portal-loop` as the +target, run: + +```sh +CHAIN_ID=portal-loop DEV_REMOTE=https://rpc.gno.land make dev +``` + +## Generate + +To generate the public assets for the project, including static assets (fonts, CSS and JavaScript... +files), run the following command. This should be used while editing CSS, JS, or +any asset files: + +```sh +make generate +``` diff --git a/gno.land/pkg/gnoweb/alias.go b/gno.land/pkg/gnoweb/alias.go index 7fb28d5cbc3..06bb3941e41 100644 --- a/gno.land/pkg/gnoweb/alias.go +++ b/gno.land/pkg/gnoweb/alias.go @@ -1,6 +1,12 @@ package gnoweb -// realm aliases +import ( + "net/http" + + "github.com/gnolang/gno/gno.land/pkg/gnoweb/components" +) + +// Aliases are gnoweb paths that are rewritten using [AliasAndRedirectMiddleware]. var Aliases = map[string]string{ "/": "/r/gnoland/home", "/about": "/r/gnoland/pages:p/about", @@ -14,7 +20,7 @@ var Aliases = map[string]string{ "/events": "/r/gnoland/events", } -// http redirects +// Redirect are gnoweb paths that are redirected using [AliasAndRedirectMiddleware]. var Redirects = map[string]string{ "/r/demo/boards:gnolang/6": "/r/demo/boards:gnolang/3", // XXX: temporary "/blog": "/r/gnoland/blog", @@ -23,5 +29,29 @@ var Redirects = map[string]string{ "/grants": "/partners", "/language": "/gnolang", "/getting-started": "/start", - "/gophercon24": "https://docs.gno.land", +} + +// AliasAndRedirectMiddleware redirects all incoming requests whose path matches +// any of the [Redirects] to the corresponding URL; and rewrites the URL path +// for incoming requests which match any of the [Aliases]. +func AliasAndRedirectMiddleware(next http.Handler, analytics bool) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Check if the request path matches a redirect + if newPath, ok := Redirects[r.URL.Path]; ok { + http.Redirect(w, r, newPath, http.StatusFound) + components.RenderRedirectComponent(w, components.RedirectData{ + To: newPath, + WithAnalytics: analytics, + }) + return + } + + // Check if the request path matches an alias + if newPath, ok := Aliases[r.URL.Path]; ok { + r.URL.Path = newPath + } + + // Call the next handler + next.ServeHTTP(w, r) + }) } diff --git a/gno.land/pkg/gnoweb/app.go b/gno.land/pkg/gnoweb/app.go new file mode 100644 index 00000000000..516d3b92186 --- /dev/null +++ b/gno.land/pkg/gnoweb/app.go @@ -0,0 +1,169 @@ +package gnoweb + +import ( + "fmt" + "log/slog" + "net/http" + "path" + "strings" + + markdown "github.com/yuin/goldmark-highlighting/v2" + + "github.com/alecthomas/chroma/v2" + chromahtml "github.com/alecthomas/chroma/v2/formatters/html" + "github.com/alecthomas/chroma/v2/styles" + "github.com/gnolang/gno/gno.land/pkg/gnoweb/components" + "github.com/gnolang/gno/tm2/pkg/bft/rpc/client" + "github.com/yuin/goldmark" + "github.com/yuin/goldmark/extension" + mdhtml "github.com/yuin/goldmark/renderer/html" +) + +// AppConfig contains configuration for the gnoweb. +type AppConfig struct { + // UnsafeHTML, if enabled, allows to use HTML in the markdown. + UnsafeHTML bool + // Analytics enables SimpleAnalytics. + Analytics bool + // NodeRemote is the remote address of the gno.land node. + NodeRemote string + // RemoteHelp is the remote of the gno.land node, as used in the help page. + RemoteHelp string + // ChainID is the chain id, used for constructing the help page. + ChainID string + // AssetsPath is the base path to the gnoweb assets. + AssetsPath string + // AssetDir, if set, will be used for assets instead of the embedded public directory. + AssetsDir string + // FaucetURL, if specified, will be the URL to which `/faucet` redirects. + FaucetURL string + // Domain is the domain used by the node. + Domain string +} + +// NewDefaultAppConfig returns a new default [AppConfig]. The default sets +// 127.0.0.1:26657 as the remote node, "dev" as the chain ID and sets up Assets +// to be served on /public/. +func NewDefaultAppConfig() *AppConfig { + const defaultRemote = "127.0.0.1:26657" + return &AppConfig{ + NodeRemote: defaultRemote, + RemoteHelp: defaultRemote, + ChainID: "dev", + AssetsPath: "/public/", + Domain: "gno.land", + } +} + +var chromaDefaultStyle = mustGetStyle("friendly") + +func mustGetStyle(name string) *chroma.Style { + s := styles.Get(name) + if s == nil { + panic("unable to get chroma style") + } + return s +} + +// NewRouter initializes the gnoweb router with the specified logger and configuration. +func NewRouter(logger *slog.Logger, cfg *AppConfig) (http.Handler, error) { + // Initialize RPC Client + client, err := client.NewHTTPClient(cfg.NodeRemote) + if err != nil { + return nil, fmt.Errorf("unable to create HTTP client: %w", err) + } + + // Configure Chroma highlighter + chromaOptions := []chromahtml.Option{ + chromahtml.WithLineNumbers(true), + chromahtml.WithLinkableLineNumbers(true, "L"), + chromahtml.WithClasses(true), + chromahtml.ClassPrefix("chroma-"), + } + chroma := chromahtml.New(chromaOptions...) + + // Configure Goldmark markdown parser + mdopts := []goldmark.Option{ + goldmark.WithExtensions( + markdown.NewHighlighting( + markdown.WithFormatOptions(chromaOptions...), + ), + extension.Table, + ), + } + if cfg.UnsafeHTML { + mdopts = append(mdopts, goldmark.WithRendererOptions(mdhtml.WithXHTML(), mdhtml.WithUnsafe())) + } + md := goldmark.New(mdopts...) + + // Configure WebClient + webcfg := HTMLWebClientConfig{ + Markdown: md, + Highlighter: NewChromaSourceHighlighter(chroma, chromaDefaultStyle), + Domain: cfg.Domain, + UnsafeHTML: cfg.UnsafeHTML, + RPCClient: client, + } + + webcli := NewHTMLClient(logger, &webcfg) + chromaStylePath := path.Join(cfg.AssetsPath, "_chroma", "style.css") + + // Setup StaticMetadata + staticMeta := StaticMetadata{ + Domain: cfg.Domain, + AssetsPath: cfg.AssetsPath, + ChromaPath: chromaStylePath, + RemoteHelp: cfg.RemoteHelp, + ChainId: cfg.ChainID, + Analytics: cfg.Analytics, + } + + // Configure WebHandler + webConfig := WebHandlerConfig{WebClient: webcli, Meta: staticMeta} + webhandler, err := NewWebHandler(logger, webConfig) + if err != nil { + return nil, fmt.Errorf("unable to create web handler: %w", err) + } + + // Setup HTTP muxer + mux := http.NewServeMux() + + // Handle web handler with alias middleware + mux.Handle("/", AliasAndRedirectMiddleware(webhandler, cfg.Analytics)) + + // Register faucet URL to `/faucet` if specified + if cfg.FaucetURL != "" { + mux.Handle("/faucet", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, cfg.FaucetURL, http.StatusFound) + components.RenderRedirectComponent(w, components.RedirectData{ + To: cfg.FaucetURL, + WithAnalytics: cfg.Analytics, + }) + })) + } + + // Handle Chroma CSS requests + // XXX: probably move this elsewhere + mux.Handle(chromaStylePath, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/css") + if err := chroma.WriteCSS(w, chromaDefaultStyle); err != nil { + logger.Error("unable to write CSS", "err", err) + http.NotFound(w, r) + } + })) + + // Handle assets path + // XXX: add caching + assetsBase := "/" + strings.Trim(cfg.AssetsPath, "/") + "/" + if cfg.AssetsDir != "" { + logger.Debug("using assets dir instead of embedded assets", "dir", cfg.AssetsDir) + mux.Handle(assetsBase, DevAssetHandler(assetsBase, cfg.AssetsDir)) + } else { + mux.Handle(assetsBase, AssetHandler()) + } + + // Handle status page + mux.Handle("/status.json", handlerStatusJSON(logger, client)) + + return mux, nil +} diff --git a/gno.land/pkg/gnoweb/gnoweb_test.go b/gno.land/pkg/gnoweb/app_test.go similarity index 57% rename from gno.land/pkg/gnoweb/gnoweb_test.go rename to gno.land/pkg/gnoweb/app_test.go index 99eb86ea07e..9f8f87b99b1 100644 --- a/gno.land/pkg/gnoweb/gnoweb_test.go +++ b/gno.land/pkg/gnoweb/app_test.go @@ -4,13 +4,13 @@ import ( "fmt" "net/http" "net/http/httptest" - "strings" "testing" "github.com/gnolang/gno/gno.land/pkg/integration" "github.com/gnolang/gno/gnovm/pkg/gnoenv" "github.com/gnolang/gno/tm2/pkg/log" - "github.com/gotuna/gotuna/test/assert" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestRoutes(t *testing.T) { @@ -24,15 +24,15 @@ func TestRoutes(t *testing.T) { status int substring string }{ - {"/", ok, "Welcome"}, // assert / gives 200 (OK). assert / contains "Welcome". + {"/", ok, "Welcome"}, // Check if / returns 200 (OK) and contains "Welcome". {"/about", ok, "blockchain"}, - {"/r/gnoland/blog", ok, ""}, // whatever content - {"/r/gnoland/blog$help", ok, "exposed"}, + {"/r/gnoland/blog", ok, ""}, // Any content + {"/r/gnoland/blog$help", ok, "AdminSetAdminAddr"}, {"/r/gnoland/blog/", ok, "admin.gno"}, - {"/r/gnoland/blog/admin.gno", ok, "func "}, - {"/r/gnoland/blog$help&func=Render", ok, "Render(...)"}, - {"/r/gnoland/blog$help&func=Render&path=foo/bar", ok, `input type="text" value="foo/bar"`}, - {"/r/gnoland/blog$help&func=NonExisting", ok, "NonExisting not found"}, + {"/r/gnoland/blog/admin.gno", ok, ">func<"}, + {"/r/gnoland/blog$help&func=Render", ok, "Render(path)"}, + {"/r/gnoland/blog$help&func=Render&path=foo/bar", ok, `value="foo/bar"`}, + // {"/r/gnoland/blog$help&func=NonExisting", ok, "NonExisting not found"}, // XXX(TODO) {"/r/demo/users:administrator", ok, "address"}, {"/r/demo/users", ok, "moul"}, {"/r/demo/users/users.gno", ok, "// State"}, @@ -40,19 +40,25 @@ func TestRoutes(t *testing.T) { {"/r/demo/deep/very/deep?arg1=val1&arg2=val2", ok, "hi ?arg1=val1&arg2=val2"}, {"/r/demo/deep/very/deep:bob", ok, "hi bob"}, {"/r/demo/deep/very/deep:bob?arg1=val1&arg2=val2", ok, "hi bob?arg1=val1&arg2=val2"}, - {"/r/demo/deep/very/deep$help", ok, "exposed"}, + {"/r/demo/deep/very/deep$help", ok, "Render"}, {"/r/demo/deep/very/deep/", ok, "render.gno"}, - {"/r/demo/deep/very/deep/render.gno", ok, "func Render("}, + {"/r/demo/deep/very/deep/render.gno", ok, ">package<"}, {"/contribute", ok, "Game of Realms"}, {"/game-of-realms", found, "/contribute"}, {"/gor", found, "/contribute"}, {"/blog", found, "/r/gnoland/blog"}, - {"/404-not-found", notFound, "/404-not-found"}, - {"/ė•„ėŠ¤í‚¤ëŦ¸ėžę°€ė•„ë‹Œę˛Ŋ로", notFound, "/ė•„ėŠ¤í‚¤ëŦ¸ėžę°€ė•„ë‹Œę˛Ŋ로"}, - {"/%ED%85%8C%EC%8A%A4%ED%8A%B8", notFound, "/테ėŠ¤íŠ¸"}, - {"/グノãƒŧ", notFound, "/グノãƒŧ"}, - {"/⚛ī¸", notFound, "/⚛ī¸"}, + {"/r/not/found/", notFound, ""}, + {"/404/not/found", notFound, ""}, + {"/ė•„ėŠ¤í‚¤ëŦ¸ėžę°€ė•„ë‹Œę˛Ŋ로", notFound, ""}, + {"/%ED%85%8C%EC%8A%A4%ED%8A%B8", notFound, ""}, + {"/グノãƒŧ", notFound, ""}, + {"/\u269B\uFE0F", notFound, ""}, // Unicode {"/p/demo/flow/LICENSE", ok, "BSD 3-Clause"}, + // Test assets + {"/public/styles.css", ok, ""}, + {"/public/js/index.js", ok, ""}, + {"/public/_chroma/style.css", ok, ""}, + {"/public/imgs/gnoland.svg", ok, ""}, } rootdir := gnoenv.RootDir() @@ -61,20 +67,21 @@ func TestRoutes(t *testing.T) { node, remoteAddr := integration.TestingInMemoryNode(t, log.NewTestingLogger(t), config) defer node.Stop() - cfg := NewDefaultConfig() + cfg := NewDefaultAppConfig() + cfg.NodeRemote = remoteAddr logger := log.NewTestingLogger(t) - // set the `remoteAddr` of the client to the listening address of the - // node, which is randomly assigned. - cfg.RemoteAddr = remoteAddr - app := MakeApp(logger, cfg) + // Initialize the router with the current node's remote address + router, err := NewRouter(logger, cfg) + require.NoError(t, err) for _, r := range routes { t.Run(fmt.Sprintf("test route %s", r.route), func(t *testing.T) { + t.Logf("input: %q", r.route) request := httptest.NewRequest(http.MethodGet, r.route, nil) response := httptest.NewRecorder() - app.Router.ServeHTTP(response, request) + router.ServeHTTP(response, request) assert.Equal(t, r.status, response.Code) assert.Contains(t, response.Body.String(), r.substring) }) @@ -83,24 +90,24 @@ func TestRoutes(t *testing.T) { func TestAnalytics(t *testing.T) { routes := []string{ - // special realms - "/", // home + // Special realms + "/", // Home "/about", "/start", - // redirects + // Redirects "/game-of-realms", "/getting-started", "/blog", "/boards", - // realm, source, help page + // Realm, source, help page "/r/gnoland/blog", "/r/gnoland/blog/admin.gno", "/r/demo/users:administrator", "/r/gnoland/blog$help", - // special pages + // Special pages "/404-not-found", } @@ -110,34 +117,42 @@ func TestAnalytics(t *testing.T) { node, remoteAddr := integration.TestingInMemoryNode(t, log.NewTestingLogger(t), config) defer node.Stop() - cfg := NewDefaultConfig() - cfg.RemoteAddr = remoteAddr - - logger := log.NewTestingLogger(t) - - t.Run("with", func(t *testing.T) { + t.Run("enabled", func(t *testing.T) { for _, route := range routes { t.Run(route, func(t *testing.T) { - ccfg := cfg // clone config - ccfg.WithAnalytics = true - app := MakeApp(logger, ccfg) + cfg := NewDefaultAppConfig() + cfg.NodeRemote = remoteAddr + cfg.Analytics = true + logger := log.NewTestingLogger(t) + + router, err := NewRouter(logger, cfg) + require.NoError(t, err) + request := httptest.NewRequest(http.MethodGet, route, nil) response := httptest.NewRecorder() - app.Router.ServeHTTP(response, request) + + router.ServeHTTP(response, request) + assert.Contains(t, response.Body.String(), "sa.gno.services") }) } }) - t.Run("without", func(t *testing.T) { + t.Run("disabled", func(t *testing.T) { for _, route := range routes { t.Run(route, func(t *testing.T) { - ccfg := cfg // clone config - ccfg.WithAnalytics = false - app := MakeApp(logger, ccfg) + cfg := NewDefaultAppConfig() + cfg.NodeRemote = remoteAddr + cfg.Analytics = false + logger := log.NewTestingLogger(t) + router, err := NewRouter(logger, cfg) + require.NoError(t, err) + request := httptest.NewRequest(http.MethodGet, route, nil) response := httptest.NewRecorder() - app.Router.ServeHTTP(response, request) - assert.Equal(t, strings.Contains(response.Body.String(), "sa.gno.services"), false) + + router.ServeHTTP(response, request) + + assert.NotContains(t, response.Body.String(), "sa.gno.services") }) } }) diff --git a/gno.land/pkg/gnoweb/components/breadcrumb.go b/gno.land/pkg/gnoweb/components/breadcrumb.go new file mode 100644 index 00000000000..8eda02a9f4d --- /dev/null +++ b/gno.land/pkg/gnoweb/components/breadcrumb.go @@ -0,0 +1,19 @@ +package components + +import ( + "io" +) + +type BreadcrumbPart struct { + Name string + URL string +} + +type BreadcrumbData struct { + Parts []BreadcrumbPart + Args string +} + +func RenderBreadcrumpComponent(w io.Writer, data BreadcrumbData) error { + return tmpl.ExecuteTemplate(w, "Breadcrumb", data) +} diff --git a/gno.land/pkg/gnoweb/components/breadcrumb.gohtml b/gno.land/pkg/gnoweb/components/breadcrumb.gohtml new file mode 100644 index 00000000000..3824eb5894f --- /dev/null +++ b/gno.land/pkg/gnoweb/components/breadcrumb.gohtml @@ -0,0 +1,18 @@ +{{ define "breadcrumb" }} +
      + {{- range $index, $part := .Parts }} + {{- if $index }} +
    1. + {{- else }} +
    2. + {{- end }} + {{ $part.Name }} +
    3. + {{- end }} + {{- if .Args }} +
    4. + {{ .Args }} +
    5. + {{- end }} +
    +{{ end }} diff --git a/gno.land/pkg/gnoweb/components/directory.go b/gno.land/pkg/gnoweb/components/directory.go new file mode 100644 index 00000000000..6e47db3b2c4 --- /dev/null +++ b/gno.land/pkg/gnoweb/components/directory.go @@ -0,0 +1,15 @@ +package components + +import ( + "io" +) + +type DirData struct { + PkgPath string + Files []string + FileCounter int +} + +func RenderDirectoryComponent(w io.Writer, data DirData) error { + return tmpl.ExecuteTemplate(w, "renderDir", data) +} diff --git a/gno.land/pkg/gnoweb/components/directory.gohtml b/gno.land/pkg/gnoweb/components/directory.gohtml new file mode 100644 index 00000000000..2254886f7af --- /dev/null +++ b/gno.land/pkg/gnoweb/components/directory.gohtml @@ -0,0 +1,38 @@ +{{ define "renderDir" }} +
    +
    + + {{ $pkgpath := .PkgPath }} +
    +
    +
    +

    {{ $pkgpath }}

    +
    +
    + Directory ¡ {{ .FileCounter }} Files +
    +
    + +
    + +
    +
    +
    + +
    +{{ end }} + diff --git a/gno.land/pkg/gnoweb/components/help.go b/gno.land/pkg/gnoweb/components/help.go new file mode 100644 index 00000000000..e819705006b --- /dev/null +++ b/gno.land/pkg/gnoweb/components/help.go @@ -0,0 +1,51 @@ +package components + +import ( + "html/template" + "io" + "strings" + + "github.com/gnolang/gno/gno.land/pkg/sdk/vm" // for error types +) + +type HelpData struct { + // Selected function + SelectedFunc string + SelectedArgs map[string]string + + RealmName string + Functions []vm.FunctionSignature + ChainId string + Remote string + PkgPath string +} + +func registerHelpFuncs(funcs template.FuncMap) { + funcs["helpFuncSignature"] = func(fsig vm.FunctionSignature) (string, error) { + var fsigStr strings.Builder + + fsigStr.WriteString(fsig.FuncName) + fsigStr.WriteRune('(') + for i, param := range fsig.Params { + if i > 0 { + fsigStr.WriteString(", ") + } + fsigStr.WriteString(param.Name) + } + fsigStr.WriteRune(')') + + return fsigStr.String(), nil + } + + funcs["getSelectedArgValue"] = func(data HelpData, param vm.NamedType) (string, error) { + if data.SelectedArgs == nil { + return "", nil + } + + return data.SelectedArgs[param.Name], nil + } +} + +func RenderHelpComponent(w io.Writer, data HelpData) error { + return tmpl.ExecuteTemplate(w, "renderHelp", data) +} diff --git a/gno.land/pkg/gnoweb/components/help.gohtml b/gno.land/pkg/gnoweb/components/help.gohtml new file mode 100644 index 00000000000..535cb56e9d6 --- /dev/null +++ b/gno.land/pkg/gnoweb/components/help.gohtml @@ -0,0 +1,110 @@ +{{ define "renderHelp" }} + {{ $data := . }} +
    +
    +
    +
    +

    {{ .RealmName }}

    +
    +
    +
    + + + + +
    +
    + + +
    +
    +
    + +
    + + {{ range .Functions }} +
    +

    {{ .FuncName }}

    +
    +
    +

    Params

    +
    + {{ $funcName := .FuncName }} + {{ range .Params }} +
    +
    + + +
    +
    + {{ end }} +
    +
    +
    +
    +

    Command

    +
    + +
    gnokey maketx call -pkgpath "{{ $.PkgPath }}" -func "{{ .FuncName }}" -gas-fee 1000000ugnot -gas-wanted 2000000 -broadcast -chainid "{{ $.ChainId }}"{{ range .Params }} -args ""{{ end }} -remote "{{ $.Remote }}" ADDRESS
    +
    +
    +
    + {{ end }} + +
    +
    +
    +{{ end }} diff --git a/gno.land/pkg/gnoweb/components/index.go b/gno.land/pkg/gnoweb/components/index.go new file mode 100644 index 00000000000..0cc020ae261 --- /dev/null +++ b/gno.land/pkg/gnoweb/components/index.go @@ -0,0 +1,47 @@ +package components + +import ( + "context" + "html/template" + "io" + "net/url" +) + +type HeadData struct { + Title string + Description string + Canonical string + Image string + URL string + ChromaPath string + AssetsPath string + Analytics bool +} + +type HeaderData struct { + RealmPath string + Breadcrumb BreadcrumbData + WebQuery url.Values +} + +type FooterData struct { + Analytics bool + AssetsPath string +} + +type IndexData struct { + HeadData + HeaderData + FooterData + Body template.HTML +} + +func IndexComponent(data IndexData) Component { + return func(ctx context.Context, tmpl *template.Template, w io.Writer) error { + return tmpl.ExecuteTemplate(w, "index", data) + } +} + +func RenderIndexComponent(w io.Writer, data IndexData) error { + return tmpl.ExecuteTemplate(w, "index", data) +} diff --git a/gno.land/pkg/gnoweb/components/index.gohtml b/gno.land/pkg/gnoweb/components/index.gohtml new file mode 100644 index 00000000000..a87decc14bf --- /dev/null +++ b/gno.land/pkg/gnoweb/components/index.gohtml @@ -0,0 +1,159 @@ +{{ define "index" }} + + + {{ template "head" .HeadData }} + + {{ template "spritesvg" }} + + + {{ template "header" .HeaderData }} + + + {{ template "main" .Body }} + + + {{ template "footer" .FooterData }} + + +{{ end }} + +{{ define "head" }} + + + + {{ .Title }} + + + + + + + + + + + {{ if .Canonical }} + + {{ end }} + + + + + + + + + + + + + + + + + + + + + + + + + + +{{ end }} + +{{ define "header" }} +
    + +
    +{{ end }} + +{{ define "main" }} + {{ . }} +{{ end }} + +{{ define "footer" }} + + +{{- if .Analytics -}} {{- template "analytics" }} {{- end -}} + +{{- end }} + +{{- define "analytics" -}} + + + +{{- end -}} diff --git a/gno.land/pkg/gnoweb/components/logosvg.gohtml b/gno.land/pkg/gnoweb/components/logosvg.gohtml new file mode 100644 index 00000000000..5ebe6460ee3 --- /dev/null +++ b/gno.land/pkg/gnoweb/components/logosvg.gohtml @@ -0,0 +1,21 @@ +{{ define "logosvg" }} + + + + + + + + + + + + + + + + + + + +{{ end }} diff --git a/gno.land/pkg/gnoweb/components/realm.go b/gno.land/pkg/gnoweb/components/realm.go new file mode 100644 index 00000000000..027760bb382 --- /dev/null +++ b/gno.land/pkg/gnoweb/components/realm.go @@ -0,0 +1,32 @@ +package components + +import ( + "context" + "html/template" + "io" + + "github.com/gnolang/gno/gno.land/pkg/gnoweb/markdown" +) + +type RealmTOCData struct { + Items []*markdown.TocItem +} + +func RealmTOCComponent(data *RealmTOCData) Component { + return func(ctx context.Context, tmpl *template.Template, w io.Writer) error { + return tmpl.ExecuteTemplate(w, "renderRealmToc", data) + } +} + +func RenderRealmTOCComponent(w io.Writer, data *RealmTOCData) error { + return tmpl.ExecuteTemplate(w, "renderRealmToc", data) +} + +type RealmData struct { + Content template.HTML + TocItems *RealmTOCData +} + +func RenderRealmComponent(w io.Writer, data RealmData) error { + return tmpl.ExecuteTemplate(w, "renderRealm", data) +} diff --git a/gno.land/pkg/gnoweb/components/realm.gohtml b/gno.land/pkg/gnoweb/components/realm.gohtml new file mode 100644 index 00000000000..55f39ef36d7 --- /dev/null +++ b/gno.land/pkg/gnoweb/components/realm.gohtml @@ -0,0 +1,41 @@ +{{ define "renderRealmToc" }} + +{{ end }} + +{{ define "renderRealm" }} +
    +
    + +
    + + {{ .Content }} +
    +
    +
    +{{ end }} diff --git a/gno.land/pkg/gnoweb/components/redirect.go b/gno.land/pkg/gnoweb/components/redirect.go new file mode 100644 index 00000000000..873ddf56ff5 --- /dev/null +++ b/gno.land/pkg/gnoweb/components/redirect.go @@ -0,0 +1,12 @@ +package components + +import "io" + +type RedirectData struct { + To string + WithAnalytics bool +} + +func RenderRedirectComponent(w io.Writer, data RedirectData) error { + return tmpl.ExecuteTemplate(w, "renderRedirect", data) +} diff --git a/gno.land/pkg/gnoweb/components/redirect.gohtml b/gno.land/pkg/gnoweb/components/redirect.gohtml new file mode 100644 index 00000000000..45dac0981cd --- /dev/null +++ b/gno.land/pkg/gnoweb/components/redirect.gohtml @@ -0,0 +1,16 @@ +{{- define "renderRedirect" -}} + + + + + + + + Redirecting to {{.To}} + + + {{.To}} + {{- if .WithAnalytics -}} {{- template "analytics" }} {{- end -}} + + +{{- end -}} \ No newline at end of file diff --git a/gno.land/pkg/gnoweb/components/source.go b/gno.land/pkg/gnoweb/components/source.go new file mode 100644 index 00000000000..23170776657 --- /dev/null +++ b/gno.land/pkg/gnoweb/components/source.go @@ -0,0 +1,20 @@ +package components + +import ( + "html/template" + "io" +) + +type SourceData struct { + PkgPath string + Files []string + FileName string + FileSize string + FileLines int + FileCounter int + FileSource template.HTML +} + +func RenderSourceComponent(w io.Writer, data SourceData) error { + return tmpl.ExecuteTemplate(w, "renderSource", data) +} diff --git a/gno.land/pkg/gnoweb/components/source.gohtml b/gno.land/pkg/gnoweb/components/source.gohtml new file mode 100644 index 00000000000..cb2430b504a --- /dev/null +++ b/gno.land/pkg/gnoweb/components/source.gohtml @@ -0,0 +1,57 @@ +{{ define "renderSource" }} +
    +
    +
    +
    +

    {{ .FileName }}

    +
    +
    + {{ .FileSize }} ¡ {{ .FileLines }} lines + +
    +
    + + +
    +
    + {{ .FileSource }} +
    +
    +
    +
    +{{ end }} diff --git a/gno.land/pkg/gnoweb/components/spritesvg.gohtml b/gno.land/pkg/gnoweb/components/spritesvg.gohtml new file mode 100644 index 00000000000..c061e97bf58 --- /dev/null +++ b/gno.land/pkg/gnoweb/components/spritesvg.gohtml @@ -0,0 +1,125 @@ +{{ define "spritesvg" }} + + + Search + + + + + + + Apps + + + + Documentation + + + + Source + + + + Content + + + + File + + + + Folder + + + + + + + + + + + Download + + + + Copy + + + + + + + + + + +{{ end }} diff --git a/gno.land/pkg/gnoweb/components/status.gohtml b/gno.land/pkg/gnoweb/components/status.gohtml new file mode 100644 index 00000000000..2321d1110bd --- /dev/null +++ b/gno.land/pkg/gnoweb/components/status.gohtml @@ -0,0 +1,12 @@ +{{ define "status" }} +
    +
    +
    + gno land +

    Error: {{ .Message }}

    +

    Something went wrong. Let’s find our way back!

    + Go Back Home +
    +
    +
    +{{ end }} diff --git a/gno.land/pkg/gnoweb/components/template.go b/gno.land/pkg/gnoweb/components/template.go new file mode 100644 index 00000000000..9c08703f460 --- /dev/null +++ b/gno.land/pkg/gnoweb/components/template.go @@ -0,0 +1,77 @@ +package components + +import ( + "bytes" + "context" + "embed" + "html/template" + "io" + "net/url" +) + +//go:embed *.gohtml +var gohtml embed.FS + +var funcMap = template.FuncMap{ + // NOTE: this method does NOT escape HTML, use with caution + "noescape_string": func(in string) template.HTML { + return template.HTML(in) //nolint:gosec + }, + // NOTE: this method does NOT escape HTML, use with caution + "noescape_bytes": func(in []byte) template.HTML { + return template.HTML(in) //nolint:gosec + }, + "queryHas": func(vals url.Values, key string) bool { + if vals == nil { + return false + } + + return vals.Has(key) + }, +} + +var tmpl = template.New("web").Funcs(funcMap) + +func init() { + registerHelpFuncs(funcMap) + tmpl.Funcs(funcMap) + + var err error + tmpl, err = tmpl.ParseFS(gohtml, "*.gohtml") + if err != nil { + panic("unable to parse embed tempalates: " + err.Error()) + } +} + +type Component func(ctx context.Context, tmpl *template.Template, w io.Writer) error + +func (c Component) Render(ctx context.Context, w io.Writer) error { + return RenderComponent(ctx, w, c) +} + +func RenderComponent(ctx context.Context, w io.Writer, c Component) error { + var render *template.Template + funcmap := template.FuncMap{ + "render": func(cf Component) (string, error) { + var buf bytes.Buffer + if err := cf(ctx, render, &buf); err != nil { + return "", err + } + + return buf.String(), nil + }, + } + + render = tmpl.Funcs(funcmap) + return c(ctx, render, w) +} + +type StatusData struct { + Message string +} + +func RenderStatusComponent(w io.Writer, message string) error { + return tmpl.ExecuteTemplate(w, "status", StatusData{ + Message: message, + }) +} diff --git a/gno.land/pkg/gnoweb/format.go b/gno.land/pkg/gnoweb/format.go new file mode 100644 index 00000000000..67911bfa985 --- /dev/null +++ b/gno.land/pkg/gnoweb/format.go @@ -0,0 +1,69 @@ +package gnoweb + +import ( + "fmt" + "io" + "path/filepath" + "strings" + + "github.com/alecthomas/chroma/v2" + "github.com/alecthomas/chroma/v2/formatters/html" + "github.com/alecthomas/chroma/v2/lexers" +) + +// FormatSource defines the interface for formatting source code. +type FormatSource interface { + Format(w io.Writer, fileName string, file []byte) error +} + +// ChromaSourceHighlighter implements the Highlighter interface using the Chroma library. +type ChromaSourceHighlighter struct { + *html.Formatter + style *chroma.Style +} + +// NewChromaSourceHighlighter constructs a new ChromaHighlighter with the given formatter and style. +func NewChromaSourceHighlighter(formatter *html.Formatter, style *chroma.Style) FormatSource { + return &ChromaSourceHighlighter{Formatter: formatter, style: style} +} + +// Format applies syntax highlighting to the source code using Chroma. +func (f *ChromaSourceHighlighter) Format(w io.Writer, fileName string, src []byte) error { + var lexer chroma.Lexer + + // Determine the lexer to be used based on the file extension. + switch strings.ToLower(filepath.Ext(fileName)) { + case ".gno": + lexer = lexers.Get("go") + case ".md": + lexer = lexers.Get("markdown") + case ".mod": + lexer = lexers.Get("gomod") + default: + lexer = lexers.Get("txt") // Unsupported file type, default to plain text. + } + + if lexer == nil { + return fmt.Errorf("unsupported lexer for file %q", fileName) + } + + iterator, err := lexer.Tokenise(nil, string(src)) + if err != nil { + return fmt.Errorf("unable to tokenise %q: %w", fileName, err) + } + + if err := f.Formatter.Format(w, f.style, iterator); err != nil { + return fmt.Errorf("unable to format source file %q: %w", fileName, err) + } + + return nil +} + +// noopFormat is a no-operation highlighter that writes the source code as-is. +type noopFormat struct{} + +// Format writes the source code to the writer without any formatting. +func (f *noopFormat) Format(w io.Writer, fileName string, src []byte) error { + _, err := w.Write(src) + return err +} diff --git a/gno.land/pkg/gnoweb/frontend/css/input.css b/gno.land/pkg/gnoweb/frontend/css/input.css new file mode 100644 index 00000000000..59e41ff4a7c --- /dev/null +++ b/gno.land/pkg/gnoweb/frontend/css/input.css @@ -0,0 +1,336 @@ +@font-face { + font-family: "Roboto"; + font-style: normal; + font-weight: 900; + font-display: swap; + src: url("./fonts/roboto/roboto-mono-normal.woff2") format("woff2"), url("./fonts/roboto/roboto-mono-normal.woff") format("woff"); +} + +@font-face { + font-family: "Inter var"; + font-weight: 100 900; + font-display: block; + font-style: oblique 0deg 10deg; + src: url("./fonts/intervar/Intervar.woff2") format("woff2"); +} + +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + html { + @apply font-interVar text-gray-600 bg-light text-200; + font-feature-settings: "kern" on, "liga" on, "calt" on, "zero" on; + -webkit-font-feature-settings: "kern" on, "liga" on, "calt" on, "zero" on; + text-size-adjust: 100%; + -moz-osx-font-smoothing: grayscale; + font-smoothing: antialiased; + font-variant-ligatures: contextual common-ligatures; + font-kerning: normal; + text-rendering: optimizeLegibility; + } + + svg { + @apply max-w-full max-h-full; + } + + form { + @apply my-0; + } + + .realm-content { + @apply text-200 break-words pt-10; + } + + .realm-content > *:first-child { + @apply !mt-0; + } + + .realm-content a { + @apply text-green-600 font-medium hover:underline; + } + + .realm-content h1, + .realm-content h2, + .realm-content h3, + .realm-content h4 { + @apply text-gray-900 mt-12 leading-tight; + } + + .realm-content h2, + .realm-content h2 * { + @apply font-bold; + } + + .realm-content h3, + .realm-content h3 *, + .realm-content h4, + .realm-content h4 * { + @apply font-semibold; + } + + .realm-content h1 + h2, + .realm-content h2 + h3, + .realm-content h3 + h4 { + @apply mt-4; + } + + .realm-content h1 { + @apply text-800 font-bold; + } + + .realm-content h2 { + @apply text-600; + } + + .realm-content h3 { + @apply text-400 text-gray-600 mt-10; + } + + .realm-content h4 { + @apply text-300 text-gray-600 font-medium my-6; + } + + .realm-content p { + @apply my-5; + } + + .realm-content strong { + @apply font-bold text-gray-900; + } + + .realm-content strong * { + @apply font-bold; + } + + .realm-content em { + @apply italic-subtle; + } + + .realm-content blockquote { + @apply border-l-4 border-gray-300 pl-4 text-gray-600 italic-subtle my-4; + } + + .realm-content ul, + .realm-content ol { + @apply pl-4 my-6; + } + + .realm-content ul li, + .realm-content ol li { + @apply mb-2; + } + + .realm-content img { + @apply max-w-full my-8; + } + + .realm-content figure { + @apply my-6 text-center; + } + + .realm-content figcaption { + @apply text-100 text-gray-600; + } + + .realm-content :not(pre) > code { + @apply bg-gray-100 px-1 py-0.5 rounded-sm text-[.96em] font-mono; + } + + .realm-content pre { + @apply bg-gray-50 p-4 rounded overflow-x-auto font-mono; + } + + .realm-content hr { + @apply border-t border-gray-100 my-10; + } + + .realm-content table { + @apply border-collapse my-8 block w-full max-w-full overflow-x-auto border-collapse; + } + + .realm-content th, + .realm-content td { + @apply border px-4 py-2 break-words whitespace-normal; + } + + .realm-content th { + @apply bg-gray-100 font-bold; + } + + .realm-content caption { + @apply mt-2 text-100 text-gray-600 text-left; + } + + .realm-content q { + @apply quotes; + } + + .realm-content q::before { + content: open-quote; + } + + .realm-content q::after { + content: close-quote; + } + + .realm-content ul ul, + .realm-content ul ol, + .realm-content ol ul, + .realm-content ol ol { + @apply mt-3 mb-2 pl-4; + } + + .realm-content ul { + @apply list-disc; + } + + .realm-content ol { + @apply list-decimal; + } + + .realm-content abbr[title] { + @apply border-b border-dotted cursor-help; + } + + .realm-content details { + @apply my-5; + } + + .realm-content summary { + @apply font-bold cursor-pointer; + } + + .realm-content a code { + @apply text-inherit; + } + + .realm-content video { + @apply max-w-full my-8; + } + + .realm-content math { + @apply font-mono; + } + + .realm-content small { + @apply text-100; + } + + .realm-content del { + @apply line-through; + } + + .realm-content sub { + @apply text-50 align-sub; + } + + .realm-content sup { + @apply text-50 align-super; + } + + .realm-content input, + .realm-content button { + @apply px-4 py-2 border border-gray-300; + } + + main :is(h1, h2, h3, h4) { + @apply scroll-mt-24; + } + + ::-moz-selection { + @apply bg-green-600 text-light; + } + ::selection { + @apply bg-green-600 text-light; + } +} + +@layer components { + /* header */ + .sidemenu .peer:checked + label > svg { + @apply text-green-600; + } + + /* toc */ + .toc-expend-btn:has(#toc-expend:checked) + nav { + @apply block; + } + .toc-expend-btn:has(#toc-expend:checked) .toc-expend-btn_ico { + @apply rotate-180; + } + + /* sidebar */ + .main-header:has(#sidemenu-summary:checked) + main #sidebar #sidebar-summary, + .main-header:has(#sidemenu-source:checked) + main #sidebar #sidebar-source, + .main-header:has(#sidemenu-docs:checked) + main #sidebar #sidebar-docs, + .main-header:has(#sidemenu-meta:checked) + main #sidebar #sidebar-meta { + @apply block; + } + + :is(.main-header:has(#sidemenu-source:checked), .main-header:has(#sidemenu-docs:checked), .main-header:has(#sidemenu-meta:checked)) + main .realm-content, + :is(.main-header:has(#sidemenu-source:checked), .main-header:has(#sidemenu-docs:checked), .main-header:has(#sidemenu-meta:checked)) .main-navigation { + @apply md:col-span-6; + } + :is(.main-header:has(#sidemenu-source:checked), .main-header:has(#sidemenu-docs:checked), .main-header:has(#sidemenu-meta:checked)) + main #sidebar, + :is(.main-header:has(#sidemenu-source:checked), .main-header:has(#sidemenu-docs:checked), .main-header:has(#sidemenu-meta:checked)) .sidemenu { + @apply md:col-span-4; + } + :is(.main-header:has(#sidemenu-source:checked), .main-header:has(#sidemenu-docs:checked), .main-header:has(#sidemenu-meta:checked)) + main #sidebar::before { + @apply absolute block content-[''] top-0 w-[50vw] h-full -left-7 bg-gray-100 z-min; + } + + /* chroma */ + main :is(.source-code) > pre { + @apply !bg-light overflow-scroll rounded py-4 md:py-8 px-1 md:px-3 font-mono text-100 md:text-200; + } + main .realm-content > pre a { + @apply hover:no-underline; + } + + main :is(.realm-content, .source-code) > pre .chroma-ln:target { + @apply !bg-transparent; + } + main :is(.realm-content, .source-code) > pre .chroma-line:has(.chroma-ln:target), + main :is(.realm-content, .source-code) > pre .chroma-line:has(.chroma-lnlinks:hover), + main :is(.realm-content, .source-code) > pre .chroma-line:has(.chroma-ln:target) .chroma-cl, + main :is(.realm-content, .source-code) > pre .chroma-line:has(.chroma-lnlinks:hover) .chroma-cl { + @apply !bg-gray-100 rounded; + } + main :is(.realm-content, .source-code) > pre .chroma-ln { + @apply scroll-mt-24; + } +} + +@layer utilities { + .italic-subtle { + font-style: oblique 10deg; + } + + .quotes { + @apply italic-subtle text-[#555] border-l-4 border-l-[#ccc] pl-4 my-6 [quotes:"“"_"”"_"‘"_"’"]; + } + + .quotes::before, + .quotes::after { + @apply [content:open-quote] text-600 text-gray-300 mr-1 [vertical-align:-0.4rem]; + } + + .quotes::after { + @apply [content:close-quote]; + } + + .text-stroke { + -webkit-text-stroke: currentColor; + -webkit-text-stroke-width: 0.6px; + } + + .no-scrollbar::-webkit-scrollbar { + display: none; + } + .no-scrollbar { + -ms-overflow-style: none; + scrollbar-width: none; + } +} diff --git a/gno.land/pkg/gnoweb/frontend/css/tx.config.js b/gno.land/pkg/gnoweb/frontend/css/tx.config.js new file mode 100644 index 00000000000..21b6a101dd6 --- /dev/null +++ b/gno.land/pkg/gnoweb/frontend/css/tx.config.js @@ -0,0 +1,72 @@ +const pxToRem = (px) => px / 16; + +export default { + content: ["./components/**/*.{gohtml,ts}"], + theme: { + screens: { + xs: `${pxToRem(360)}rem`, + sm: `${pxToRem(480)}rem`, + md: `${pxToRem(640)}rem`, + lg: `${pxToRem(820)}rem`, + xl: `${pxToRem(1020)}rem`, + xxl: `${pxToRem(1366)}rem`, + max: `${pxToRem(1580)}rem`, + }, + zIndex: { + min: "-1", + 1: "1", + 2: "2", + 100: "100", + max: "9999", + }, + container: { + center: true, + padding: `${pxToRem(40)}rem`, + }, + borderRadius: { + sm: `${pxToRem(4)}rem`, + DEFAULT: `${pxToRem(6)}rem`, + }, + colors: { + light: "#FFFFFF", + gray: { + 50: "#F0F0F0", // Background color + 100: "#E2E2E2", // Title dark color + 200: "#BDBDBD", // Content dark color + 300: "#999999", // Muted color + 400: "#7C7C7C", // Border color + 600: "#54595D", // Content color + 800: "#131313", // Background dark color + 900: "#080809", // Title color + }, + green: { + 400: "#2D8D72", // Primary dark color + 600: "#226C57", // Primary light color + }, + transparent: "transparent", + current: "currentColor", + inherit: "inherit", + }, + fontFamily: { + mono: ["Roboto", 'Menlo, Consolas, "Ubuntu Mono", "Roboto Mono", "DejaVu Sans Mono", monospace;'], + interVar: [ + '"Inter var"', + '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji", sans-serif', + ], + }, + fontSize: { + 0: "0", + 50: `${pxToRem(12)}rem`, + 100: `${pxToRem(14)}rem`, + 200: `${pxToRem(16)}rem`, + 300: `${pxToRem(18)}rem`, + 400: `${pxToRem(20)}rem`, + 500: `${pxToRem(22)}rem`, + 600: `${pxToRem(24)}rem`, + 700: `${pxToRem(32)}rem`, + 800: `${pxToRem(38)}rem`, + 900: `${pxToRem(42)}rem`, + }, + }, + plugins: [], +}; diff --git a/gno.land/pkg/gnoweb/frontend/js/copy.ts b/gno.land/pkg/gnoweb/frontend/js/copy.ts new file mode 100644 index 00000000000..f3e5c725783 --- /dev/null +++ b/gno.land/pkg/gnoweb/frontend/js/copy.ts @@ -0,0 +1,105 @@ +class Copy { + private DOM: { + el: HTMLElement | null; + }; + private static FEEDBACK_DELAY = 750; + + private btnClicked: HTMLElement | null = null; + private btnClickedIcons: HTMLElement[] = []; + private isAnimationRunning: boolean = false; + + private static SELECTORS = { + button: "[data-copy-btn]", + icon: `[data-copy-icon] > use`, + content: (id: string) => `[data-copy-content="${id}"]`, + }; + + constructor() { + this.DOM = { + el: document.querySelector("main"), + }; + + if (this.DOM.el) { + this.init(); + } else { + console.warn("Copy: Main container not found."); + } + } + + private init(): void { + this.bindEvents(); + } + + private bindEvents(): void { + this.DOM.el?.addEventListener("click", this.handleClick.bind(this)); + } + + private handleClick(event: Event): void { + const target = event.target as HTMLElement; + const button = target.closest(Copy.SELECTORS.button); + + if (!button) return; + + this.btnClicked = button; + this.btnClickedIcons = Array.from(button.querySelectorAll(Copy.SELECTORS.icon)); + + const contentId = button.getAttribute("data-copy-btn"); + if (!contentId) { + console.warn("Copy: No content ID found on the button."); + return; + } + + const codeBlock = this.DOM.el?.querySelector(Copy.SELECTORS.content(contentId)); + if (codeBlock) { + this.copyToClipboard(codeBlock, this.btnClickedIcons); + } else { + console.warn(`Copy: No content found for ID "${contentId}".`); + } + } + + private sanitizeContent(codeBlock: HTMLElement): string { + const html = codeBlock.innerHTML.replace(/]*class="chroma-ln"[^>]*>[\s\S]*?<\/span>/g, ""); + + const tempDiv = document.createElement("div"); + tempDiv.innerHTML = html; + + return tempDiv.textContent?.trim() || ""; + } + + private toggleIcons(icons: HTMLElement[]): void { + icons.forEach((icon) => { + icon.classList.toggle("hidden"); + }); + } + + private showFeedback(icons: HTMLElement[]): void { + if (!this.btnClicked || this.isAnimationRunning === true) return; + + this.isAnimationRunning = true; + this.toggleIcons(icons); + window.setTimeout(() => { + this.toggleIcons(icons); + this.isAnimationRunning = false; + }, Copy.FEEDBACK_DELAY); + } + + private async copyToClipboard(codeBlock: HTMLElement, icons: HTMLElement[]): Promise { + const sanitizedText = this.sanitizeContent(codeBlock); + + if (!navigator.clipboard) { + console.error("Copy: Clipboard API is not supported in this browser."); + this.showFeedback(icons); + return; + } + + try { + await navigator.clipboard.writeText(sanitizedText); + this.showFeedback(icons); + } catch (err) { + console.error("Copy: Error while copying text.", err); + this.showFeedback(icons); + } + } +} + +export default () => new Copy(); diff --git a/gno.land/pkg/gnoweb/frontend/js/index.ts b/gno.land/pkg/gnoweb/frontend/js/index.ts new file mode 100644 index 00000000000..3927f794b94 --- /dev/null +++ b/gno.land/pkg/gnoweb/frontend/js/index.ts @@ -0,0 +1,42 @@ +(() => { + interface Module { + selector: string; + path: string; + } + + const modules: Record = { + copy: { + selector: "[data-copy-btn]", + path: "/public/js/copy.js", + }, + help: { + selector: "#help", + path: "/public/js/realmhelp.js", + }, + searchBar: { + selector: "#header-searchbar", + path: "/public/js/searchbar.js", + }, + }; + + const loadModuleIfExists = async ({ selector, path }: Module): Promise => { + const element = document.querySelector(selector); + if (element) { + try { + const module = await import(path); + module.default(); + } catch (err) { + console.error(`Error while loading script ${path}:`, err); + } + } else { + console.warn(`Module not loaded: no element matches selector "${selector}"`); + } + }; + + const initModules = async (): Promise => { + const promises = Object.values(modules).map((module) => loadModuleIfExists(module)); + await Promise.all(promises); + }; + + document.addEventListener("DOMContentLoaded", initModules); +})(); diff --git a/gno.land/pkg/gnoweb/frontend/js/realmhelp.ts b/gno.land/pkg/gnoweb/frontend/js/realmhelp.ts new file mode 100644 index 00000000000..d72102e2a2e --- /dev/null +++ b/gno.land/pkg/gnoweb/frontend/js/realmhelp.ts @@ -0,0 +1,169 @@ +import { debounce } from "./utils"; + +class Help { + private DOM: { + el: HTMLElement | null; + funcs: HTMLElement[]; + addressInput: HTMLInputElement | null; + cmdModeSelect: HTMLSelectElement | null; + }; + + private funcList: HelpFunc[]; + + private static SELECTORS = { + container: "#help", + func: "[data-func]", + addressInput: "[data-role='help-input-addr']", + cmdModeSelect: "[data-role='help-select-mode']", + }; + + constructor() { + this.DOM = { + el: document.querySelector(Help.SELECTORS.container), + funcs: [], + addressInput: null, + cmdModeSelect: null, + }; + + this.funcList = []; + + if (this.DOM.el) { + this.init(); + } else { + console.warn("Help: Main container not found."); + } + } + + private init(): void { + const { el } = this.DOM; + if (!el) return; + + this.DOM.funcs = Array.from(el.querySelectorAll(Help.SELECTORS.func)); + this.DOM.addressInput = el.querySelector(Help.SELECTORS.addressInput); + this.DOM.cmdModeSelect = el.querySelector(Help.SELECTORS.cmdModeSelect); + + this.funcList = this.DOM.funcs.map((funcEl) => new HelpFunc(funcEl)); + + this.restoreAddress(); + this.bindEvents(); + } + + private restoreAddress(): void { + const { addressInput } = this.DOM; + if (addressInput) { + const storedAddress = localStorage.getItem("helpAddressInput"); + if (storedAddress) { + addressInput.value = storedAddress; + this.funcList.forEach((func) => func.updateAddr(storedAddress)); + } + } + } + + private bindEvents(): void { + const { addressInput, cmdModeSelect } = this.DOM; + + const debouncedUpdate = debounce((addressInput: HTMLInputElement) => { + const address = addressInput.value; + + localStorage.setItem("helpAddressInput", address); + this.funcList.forEach((func) => func.updateAddr(address)); + }); + addressInput?.addEventListener("input", () => debouncedUpdate(addressInput)); + + cmdModeSelect?.addEventListener("change", (e) => { + const target = e.target as HTMLSelectElement; + this.funcList.forEach((func) => func.updateMode(target.value)); + }); + } +} + +class HelpFunc { + private DOM: { + el: HTMLElement; + addrs: HTMLElement[]; + args: HTMLElement[]; + modes: HTMLElement[]; + paramInputs: HTMLInputElement[]; + }; + + private funcName: string | null; + + private static SELECTORS = { + address: "[data-role='help-code-address']", + args: "[data-role='help-code-args']", + mode: "[data-code-mode]", + paramInput: "[data-role='help-param-input']", + }; + + constructor(el: HTMLElement) { + this.DOM = { + el, + addrs: Array.from(el.querySelectorAll(HelpFunc.SELECTORS.address)), + args: Array.from(el.querySelectorAll(HelpFunc.SELECTORS.args)), + modes: Array.from(el.querySelectorAll(HelpFunc.SELECTORS.mode)), + paramInputs: Array.from(el.querySelectorAll(HelpFunc.SELECTORS.paramInput)), + }; + + this.funcName = el.dataset.func || null; + + this.initializeArgs(); + this.bindEvents(); + } + + private static sanitizeArgsInput(input: HTMLInputElement) { + const paramName = input.dataset.param || ""; + const paramValue = input.value.trim(); + + if (!paramName) { + console.warn("sanitizeArgsInput: param is missing in arg input dataset."); + } + + return { paramName, paramValue }; + } + + private bindEvents(): void { + const debouncedUpdate = debounce((paramName: string, paramValue: string) => { + if (paramName) this.updateArg(paramName, paramValue); + }); + + this.DOM.el.addEventListener("input", (e) => { + const target = e.target as HTMLInputElement; + if (target.dataset.role === "help-param-input") { + const { paramName, paramValue } = HelpFunc.sanitizeArgsInput(target); + debouncedUpdate(paramName, paramValue); + } + }); + } + + private initializeArgs(): void { + this.DOM.paramInputs.forEach((input) => { + const { paramName, paramValue } = HelpFunc.sanitizeArgsInput(input); + if (paramName) this.updateArg(paramName, paramValue); + }); + } + + public updateArg(paramName: string, paramValue: string): void { + this.DOM.args + .filter((arg) => arg.dataset.arg === paramName) + .forEach((arg) => { + arg.textContent = paramValue || ""; + }); + } + + public updateAddr(addr: string): void { + this.DOM.addrs.forEach((DOMaddr) => { + DOMaddr.textContent = addr.trim() || "ADDRESS"; + }); + } + + public updateMode(mode: string): void { + this.DOM.modes.forEach((cmd) => { + const isVisible = cmd.dataset.codeMode === mode; + cmd.classList.toggle("inline", isVisible); + cmd.classList.toggle("hidden", !isVisible); + cmd.dataset.copyContent = isVisible ? `help-cmd-${this.funcName}` : ""; + }); + } +} + +export default () => new Help(); diff --git a/gno.land/pkg/gnoweb/frontend/js/searchbar.ts b/gno.land/pkg/gnoweb/frontend/js/searchbar.ts new file mode 100644 index 00000000000..6cca444aa0f --- /dev/null +++ b/gno.land/pkg/gnoweb/frontend/js/searchbar.ts @@ -0,0 +1,74 @@ +class SearchBar { + private DOM: { + el: HTMLElement | null; + inputSearch: HTMLInputElement | null; + breadcrumb: HTMLElement | null; + }; + + private baseUrl: string; + + private static SELECTORS = { + container: "#header-searchbar", + inputSearch: "[data-role='header-input-search']", + breadcrumb: "[data-role='header-breadcrumb-search']", + }; + + constructor() { + this.DOM = { + el: document.querySelector(SearchBar.SELECTORS.container), + inputSearch: null, + breadcrumb: null, + }; + + this.baseUrl = window.location.origin; + + if (this.DOM.el) { + this.init(); + } else { + console.warn("SearchBar: Main container not found."); + } + } + + private init(): void { + const { el } = this.DOM; + + this.DOM.inputSearch = el?.querySelector(SearchBar.SELECTORS.inputSearch) ?? null; + this.DOM.breadcrumb = el?.querySelector(SearchBar.SELECTORS.breadcrumb) ?? null; + + if (!this.DOM.inputSearch) { + console.warn("SearchBar: Input element for search not found."); + } + + this.bindEvents(); + } + + private bindEvents(): void { + this.DOM.el?.addEventListener("submit", (e) => { + e.preventDefault(); + this.searchUrl(); + }); + } + + public searchUrl(): void { + const input = this.DOM.inputSearch?.value.trim(); + + if (input) { + let url = input; + + // Check if the URL has a proper scheme + if (!/^https?:\/\//i.test(url)) { + url = `${this.baseUrl}${url.startsWith("/") ? "" : "/"}${url}`; + } + + try { + window.location.href = new URL(url).href; + } catch (error) { + console.error("SearchBar: Invalid URL. Please enter a valid URL starting with http:// or https://."); + } + } else { + console.error("SearchBar: Please enter a URL to search."); + } + } +} + +export default () => new SearchBar(); diff --git a/gno.land/pkg/gnoweb/frontend/js/utils.ts b/gno.land/pkg/gnoweb/frontend/js/utils.ts new file mode 100644 index 00000000000..83de509efa5 --- /dev/null +++ b/gno.land/pkg/gnoweb/frontend/js/utils.ts @@ -0,0 +1,12 @@ +export function debounce void>(func: T, delay: number = 250): (...args: Parameters) => void { + let timeoutId: ReturnType | undefined; + + return function (this: any, ...args: Parameters) { + if (timeoutId !== undefined) { + clearTimeout(timeoutId); + } + timeoutId = setTimeout(() => { + func.apply(this, args); + }, delay); + }; +} diff --git a/gno.land/pkg/gnoweb/static/img/favicon.ico b/gno.land/pkg/gnoweb/frontend/static/favicon.ico similarity index 100% rename from gno.land/pkg/gnoweb/static/img/favicon.ico rename to gno.land/pkg/gnoweb/frontend/static/favicon.ico diff --git a/gno.land/pkg/gnoweb/frontend/static/fonts/intervar/Intervar.woff2 b/gno.land/pkg/gnoweb/frontend/static/fonts/intervar/Intervar.woff2 new file mode 100644 index 00000000000..891fc5cc567 Binary files /dev/null and b/gno.land/pkg/gnoweb/frontend/static/fonts/intervar/Intervar.woff2 differ diff --git a/gno.land/pkg/gnoweb/frontend/static/fonts/roboto/roboto-mono-normal.woff b/gno.land/pkg/gnoweb/frontend/static/fonts/roboto/roboto-mono-normal.woff new file mode 100644 index 00000000000..2c58fe2d6d7 Binary files /dev/null and b/gno.land/pkg/gnoweb/frontend/static/fonts/roboto/roboto-mono-normal.woff differ diff --git a/gno.land/pkg/gnoweb/frontend/static/fonts/roboto/roboto-mono-normal.woff2 b/gno.land/pkg/gnoweb/frontend/static/fonts/roboto/roboto-mono-normal.woff2 new file mode 100644 index 00000000000..53d081f3a53 Binary files /dev/null and b/gno.land/pkg/gnoweb/frontend/static/fonts/roboto/roboto-mono-normal.woff2 differ diff --git a/gno.land/pkg/gnoweb/frontend/static/imgs/gnoland.svg b/gno.land/pkg/gnoweb/frontend/static/imgs/gnoland.svg new file mode 100644 index 00000000000..30d2f3ef56a --- /dev/null +++ b/gno.land/pkg/gnoweb/frontend/static/imgs/gnoland.svg @@ -0,0 +1,4 @@ + + + + diff --git a/gno.land/pkg/gnoweb/gnoweb.go b/gno.land/pkg/gnoweb/gnoweb.go deleted file mode 100644 index 40d027d84b9..00000000000 --- a/gno.land/pkg/gnoweb/gnoweb.go +++ /dev/null @@ -1,608 +0,0 @@ -package gnoweb - -import ( - "bytes" - "embed" - "encoding/json" - "errors" - "fmt" - "io" - "io/fs" - "log/slog" - "net/http" - "net/url" - "os" - "path/filepath" - "regexp" - "runtime" - "strings" - "time" - - "github.com/gnolang/gno/gnovm" - "github.com/gnolang/gno/tm2/pkg/amino" - abci "github.com/gnolang/gno/tm2/pkg/bft/abci/types" - "github.com/gnolang/gno/tm2/pkg/bft/rpc/client" - "github.com/gorilla/mux" - "github.com/gotuna/gotuna" - - // for static files - "github.com/gnolang/gno/gno.land/pkg/gnoweb/static" - "github.com/gnolang/gno/gno.land/pkg/sdk/vm" // for error types - // "github.com/gnolang/gno/tm2/pkg/sdk" // for baseapp (info, status) -) - -const ( - qFileStr = "vm/qfile" - gnowebArgsSeparator = "$" - urlQuerySeparator = "?" -) - -//go:embed views/* -var defaultViewsFiles embed.FS - -type Config struct { - RemoteAddr string - CaptchaSite string - FaucetURL string - ViewsDir string - HelpChainID string - HelpRemote string - WithAnalytics bool - WithHTML bool -} - -func NewDefaultConfig() Config { - return Config{ - RemoteAddr: "127.0.0.1:26657", - CaptchaSite: "", - FaucetURL: "http://localhost:5050", - ViewsDir: "", - HelpChainID: "dev", - HelpRemote: "127.0.0.1:26657", - WithAnalytics: false, - WithHTML: false, - } -} - -func MakeApp(logger *slog.Logger, cfg Config) gotuna.App { - var viewFiles fs.FS - - // Get specific views directory if specified - if cfg.ViewsDir != "" { - viewFiles = os.DirFS(cfg.ViewsDir) - } else { - // Get embed views - var err error - viewFiles, err = fs.Sub(defaultViewsFiles, "views") - if err != nil { - panic("unable to get views directory from embed fs: " + err.Error()) - } - } - - app := gotuna.App{ - ViewFiles: viewFiles, - Router: gotuna.NewMuxRouter(), - Static: static.EmbeddedStatic, - } - - for from, to := range Aliases { - app.Router.Handle(from, handlerRealmAlias(logger, app, &cfg, to)) - } - - for from, to := range Redirects { - app.Router.Handle(from, handlerRedirect(logger, app, &cfg, to)) - } - // realm routes - // NOTE: see rePathPart. - app.Router.Handle("/r/{rlmname:[a-z][a-z0-9_]*(?:/[a-z][a-z0-9_]*)+}/{filename:(?:(?:.*\\.(?:gno|md|txt|mod)$)|(?:LICENSE$))?}", handlerRealmFile(logger, app, &cfg)) - app.Router.Handle("/r/{rlmname:[a-z][a-z0-9_]*(?:/[a-z][a-z0-9_]*)+}{args:(?:\\$.*)?}", handlerRealmMain(logger, app, &cfg)) - app.Router.Handle("/r/{rlmname:[a-z][a-z0-9_]*(?:/[a-z][a-z0-9_]*)+}:{querystr:[^$]*}{args:(?:\\$.*)?}", handlerRealmRender(logger, app, &cfg)) - app.Router.Handle("/p/{filepath:.*}", handlerPackageFile(logger, app, &cfg)) - - // other - app.Router.Handle("/faucet", handlerFaucet(logger, app, &cfg)) - app.Router.Handle("/static/{path:.+}", handlerStaticFile(logger, app, &cfg)) - app.Router.Handle("/favicon.ico", handlerFavicon(logger, app, &cfg)) - - // api - app.Router.Handle("/status.json", handlerStatusJSON(logger, app, &cfg)) - - app.Router.NotFoundHandler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - path := r.RequestURI - handleNotFound(logger, app, &cfg, path, w, r) - }) - return app -} - -var ( - inlineCodePattern = regexp.MustCompile("`[^`]*`") - htmlTagPattern = regexp.MustCompile(`<\/?\w+[^>]*?>`) -) - -func sanitizeContent(cfg *Config, content string) string { - if cfg.WithHTML { - return content - } - - placeholders := map[string]string{} - contentWithPlaceholders := inlineCodePattern.ReplaceAllStringFunc(content, func(match string) string { - placeholder := fmt.Sprintf("__GNOMDCODE_%d__", len(placeholders)) - placeholders[placeholder] = match - return placeholder - }) - - sanitizedContent := htmlTagPattern.ReplaceAllString(contentWithPlaceholders, "") - - if len(placeholders) > 0 { - for placeholder, code := range placeholders { - sanitizedContent = strings.ReplaceAll(sanitizedContent, placeholder, code) - } - } - - return sanitizedContent -} - -// handlerRealmAlias is used to render official pages from realms. -// url is intended to be shorter. -// UX is intended to be more minimalistic. -// A link to the realm realm is added. -func handlerRealmAlias(logger *slog.Logger, app gotuna.App, cfg *Config, rlmpath string) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - rlmfullpath := "gno.land" + rlmpath - querystr := "" // XXX: "?gnoweb-alias=1" - parts := strings.Split(rlmpath, ":") - switch len(parts) { - case 1: // continue - case 2: // r/realm:querystr - rlmfullpath = "gno.land" + parts[0] - querystr = parts[1] + querystr - default: - panic("should not happen") - } - rlmname := strings.TrimPrefix(rlmfullpath, "gno.land/r/") - qpath := "vm/qrender" - data := []byte(fmt.Sprintf("%s:%s", rlmfullpath, querystr)) - res, err := makeRequest(logger, cfg, qpath, data) - if err != nil { - writeError(logger, w, fmt.Errorf("gnoweb failed to query gnoland: %w", err)) - return - } - - queryParts := strings.Split(querystr, "/") - pathLinks := []pathLink{} - for i, part := range queryParts { - pathLinks = append(pathLinks, pathLink{ - URL: "/r/" + rlmname + ":" + strings.Join(queryParts[:i+1], "/"), - Text: part, - }) - } - - tmpl := app.NewTemplatingEngine() - // XXX: extract title from realm's output - // XXX: extract description from realm's output - tmpl.Set("RealmName", rlmname) - tmpl.Set("RealmPath", rlmpath) - tmpl.Set("Query", querystr) - tmpl.Set("PathLinks", pathLinks) - tmpl.Set("Contents", sanitizeContent(cfg, string(res.Data))) - tmpl.Set("Config", cfg) - tmpl.Set("IsAlias", true) - tmpl.Render(w, r, "realm_render.html", "funcs.html") - }) -} - -func handlerFaucet(logger *slog.Logger, app gotuna.App, cfg *Config) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - app.NewTemplatingEngine(). - Set("Config", cfg). - Render(w, r, "faucet.html", "funcs.html") - }) -} - -func handlerStatusJSON(logger *slog.Logger, app gotuna.App, cfg *Config) http.Handler { - startedAt := time.Now() - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - var ret struct { - Gnoland struct { - Connected bool `json:"connected"` - Error *string `json:"error,omitempty"` - Height *int64 `json:"height,omitempty"` - // processed txs - // active connections - - Version *string `json:"version,omitempty"` - // Uptime *float64 `json:"uptime-seconds,omitempty"` - // Goarch *string `json:"goarch,omitempty"` - // Goos *string `json:"goos,omitempty"` - // GoVersion *string `json:"go-version,omitempty"` - // NumCPU *int `json:"num_cpu,omitempty"` - } `json:"gnoland"` - Website struct { - // Version string `json:"version"` - Uptime float64 `json:"uptime-seconds"` - Goarch string `json:"goarch"` - Goos string `json:"goos"` - GoVersion string `json:"go-version"` - NumCPU int `json:"num_cpu"` - } `json:"website"` - } - ret.Website.Uptime = time.Since(startedAt).Seconds() - ret.Website.Goarch = runtime.GOARCH - ret.Website.Goos = runtime.GOOS - ret.Website.NumCPU = runtime.NumCPU() - ret.Website.GoVersion = runtime.Version() - - ret.Gnoland.Connected = true - res, err := makeRequest(logger, cfg, ".app/version", []byte{}) - if err != nil { - ret.Gnoland.Connected = false - errmsg := err.Error() - ret.Gnoland.Error = &errmsg - } else { - version := string(res.Value) - ret.Gnoland.Version = &version - ret.Gnoland.Height = &res.Height - } - - out, _ := json.MarshalIndent(ret, "", " ") - w.Header().Set("Content-Type", "application/json") - w.Write(out) - }) -} - -func handlerRedirect(logger *slog.Logger, app gotuna.App, cfg *Config, to string) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - http.Redirect(w, r, to, http.StatusFound) - tmpl := app.NewTemplatingEngine() - tmpl.Set("To", to) - tmpl.Set("Config", cfg) - tmpl.Render(w, r, "redirect.html", "funcs.html") - }) -} - -func handlerRealmMain(logger *slog.Logger, app gotuna.App, cfg *Config) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - args, err := parseGnowebArgs(r.RequestURI) - if err != nil { - writeError(logger, w, err) - return - } - - vars := mux.Vars(r) - rlmname := vars["rlmname"] - rlmpath := "gno.land/r/" + rlmname - - logger.Info("handling", "name", rlmname, "path", rlmpath) - if args.Has("help") { - // Render function helper. - funcName := args.Get("func") - qpath := "vm/qfuncs" - data := []byte(rlmpath) - res, err := makeRequest(logger, cfg, qpath, data) - if err != nil { - writeError(logger, w, fmt.Errorf("request failed: %w", err)) - return - } - var fsigs vm.FunctionSignatures - amino.MustUnmarshalJSON(res.Data, &fsigs) - // Fill fsigs with query parameters. - for i := range fsigs { - fsig := &(fsigs[i]) - for j := range fsig.Params { - param := &(fsig.Params[j]) - value := args.Get(param.Name) - param.Value = value - } - } - // Render template. - tmpl := app.NewTemplatingEngine() - tmpl.Set("FuncName", funcName) - tmpl.Set("RealmPath", rlmpath) - tmpl.Set("DirPath", pathOf(rlmpath)) - tmpl.Set("FunctionSignatures", fsigs) - tmpl.Set("Config", cfg) - tmpl.Render(w, r, "realm_help.html", "funcs.html") - } else { - // Ensure realm exists. TODO optimize. - qpath := qFileStr - data := []byte(rlmpath) - _, err := makeRequest(logger, cfg, qpath, data) - if err != nil { - writeError(logger, w, errors.New("error querying realm package")) - return - } - // Render blank query path, /r/REALM:. - handleRealmRender(logger, app, cfg, w, r) - } - }) -} - -type pathLink struct { - URL string - Text string -} - -func handlerRealmRender(logger *slog.Logger, app gotuna.App, cfg *Config) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - handleRealmRender(logger, app, cfg, w, r) - }) -} - -func handleRealmRender(logger *slog.Logger, app gotuna.App, cfg *Config, w http.ResponseWriter, r *http.Request) { - gnowebArgs, err := parseGnowebArgs(r.RequestURI) - if err != nil { - writeError(logger, w, err) - return - } - - queryArgs, err := parseQueryArgs(r.RequestURI) - if err != nil { - writeError(logger, w, err) - return - } - - var urlQuery, gnowebQuery string - if len(queryArgs) > 0 { - urlQuery = urlQuerySeparator + queryArgs.Encode() - } - if len(gnowebArgs) > 0 { - gnowebQuery = gnowebArgsSeparator + gnowebArgs.Encode() - } - - vars := mux.Vars(r) - rlmname := vars["rlmname"] - rlmpath := "gno.land/r/" + rlmname - querystr := vars["querystr"] - if r.URL.Path == "/r/"+rlmname+":" { - // Redirect to /r/REALM if querypath is empty. - http.Redirect(w, r, "/r/"+rlmname+urlQuery+gnowebQuery, http.StatusFound) - return - } - - qpath := "vm/qrender" - data := []byte(fmt.Sprintf("%s:%s", rlmpath, querystr+urlQuery)) - res, err := makeRequest(logger, cfg, qpath, data) - if err != nil { - // XXX hack - if strings.Contains(err.Error(), "Render not declared") { - res = &abci.ResponseQuery{} - res.Data = []byte("realm package has no Render() function") - } else { - writeError(logger, w, err) - return - } - } - - dirdata := []byte(rlmpath) - dirres, err := makeRequest(logger, cfg, qFileStr, dirdata) - if err != nil { - writeError(logger, w, err) - return - } - hasReadme := bytes.Contains(append(dirres.Data, '\n'), []byte("README.md\n")) - - // linkify querystr. - queryParts := strings.Split(querystr, "/") - pathLinks := []pathLink{} - for i, part := range queryParts { - rlmpath := strings.Join(queryParts[:i+1], "/") - - // Add URL query arguments to the last breadcrumb item's URL - if i+1 == len(queryParts) { - rlmpath = rlmpath + urlQuery + gnowebQuery - } - - pathLinks = append(pathLinks, pathLink{ - URL: "/r/" + rlmname + ":" + rlmpath, - Text: part, - }) - } - - // Render template. - tmpl := app.NewTemplatingEngine() - // XXX: extract title from realm's output - // XXX: extract description from realm's output - tmpl.Set("RealmName", rlmname) - tmpl.Set("RealmPath", rlmpath) - tmpl.Set("Query", querystr) - tmpl.Set("PathLinks", pathLinks) - tmpl.Set("Contents", sanitizeContent(cfg, string(res.Data))) - tmpl.Set("Config", cfg) - tmpl.Set("HasReadme", hasReadme) - tmpl.Render(w, r, "realm_render.html", "funcs.html") -} - -func handlerRealmFile(logger *slog.Logger, app gotuna.App, cfg *Config) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - diruri := "gno.land/r/" + vars["rlmname"] - filename := vars["filename"] - renderPackageFile(logger, app, cfg, w, r, diruri, filename) - }) -} - -func handlerPackageFile(logger *slog.Logger, app gotuna.App, cfg *Config) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - pkgpath := "gno.land/p/" + vars["filepath"] - diruri, filename := gnovm.SplitFilepath(pkgpath) - if filename == "" && diruri == pkgpath { - // redirect to diruri + "/" - http.Redirect(w, r, "/p/"+vars["filepath"]+"/", http.StatusFound) - return - } - renderPackageFile(logger, app, cfg, w, r, diruri, filename) - }) -} - -func renderPackageFile(logger *slog.Logger, app gotuna.App, cfg *Config, w http.ResponseWriter, r *http.Request, diruri string, filename string) { - if filename == "" { - // Request is for a folder. - qpath := qFileStr - data := []byte(diruri) - res, err := makeRequest(logger, cfg, qpath, data) - if err != nil { - writeError(logger, w, err) - return - } - files := strings.Split(string(res.Data), "\n") - // Render template. - tmpl := app.NewTemplatingEngine() - tmpl.Set("DirURI", diruri) - tmpl.Set("DirPath", pathOf(diruri)) - tmpl.Set("Files", files) - tmpl.Set("Config", cfg) - tmpl.Render(w, r, "package_dir.html", "funcs.html") - } else { - // Request is for a file. - filepath := diruri + "/" + filename - qpath := qFileStr - data := []byte(filepath) - res, err := makeRequest(logger, cfg, qpath, data) - if err != nil { - writeError(logger, w, err) - return - } - // Render template. - tmpl := app.NewTemplatingEngine() - tmpl.Set("DirURI", diruri) - tmpl.Set("DirPath", pathOf(diruri)) - tmpl.Set("FileName", filename) - tmpl.Set("FileContents", string(res.Data)) - tmpl.Set("Config", cfg) - tmpl.Render(w, r, "package_file.html", "funcs.html") - } -} - -func makeRequest(log *slog.Logger, cfg *Config, qpath string, data []byte) (res *abci.ResponseQuery, err error) { - opts2 := client.ABCIQueryOptions{ - // Height: height, XXX - // Prove: false, XXX - } - remote := cfg.RemoteAddr - cli, err := client.NewHTTPClient(remote) - if err != nil { - return nil, fmt.Errorf("unable to create HTTP client, %w", err) - } - - qres, err := cli.ABCIQueryWithOptions( - qpath, data, opts2) - if err != nil { - log.Error("request error", "path", qpath, "error", err) - return nil, fmt.Errorf("unable to query path %q: %w", qpath, err) - } - if qres.Response.Error != nil { - log.Error("response error", "path", qpath, "log", qres.Response.Log) - return nil, qres.Response.Error - } - return &qres.Response, nil -} - -func handlerStaticFile(logger *slog.Logger, app gotuna.App, cfg *Config) http.Handler { - fs := http.FS(app.Static) - fileapp := http.StripPrefix("/static", http.FileServer(fs)) - - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - fpath := filepath.Clean(vars["path"]) - f, err := fs.Open(fpath) - if os.IsNotExist(err) { - handleNotFound(logger, app, cfg, fpath, w, r) - return - } - stat, err := f.Stat() - if err != nil || stat.IsDir() { - handleNotFound(logger, app, cfg, fpath, w, r) - return - } - - // TODO: ModTime doesn't work for embed? - // w.Header().Set("ETag", fmt.Sprintf("%x", stat.ModTime().UnixNano())) - // w.Header().Set("Cache-Control", fmt.Sprintf("max-age=%s", "31536000")) - fileapp.ServeHTTP(w, r) - }) -} - -func handlerFavicon(logger *slog.Logger, app gotuna.App, cfg *Config) http.Handler { - fs := http.FS(app.Static) - - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - fpath := "img/favicon.ico" - f, err := fs.Open(fpath) - if os.IsNotExist(err) { - handleNotFound(logger, app, cfg, fpath, w, r) - return - } - w.Header().Set("Content-Type", "image/x-icon") - w.Header().Set("Cache-Control", "public, max-age=604800") // 7d - io.Copy(w, f) - }) -} - -func handleNotFound(logger *slog.Logger, app gotuna.App, cfg *Config, path string, w http.ResponseWriter, r *http.Request) { - // decode path for non-ascii characters - decodedPath, err := url.PathUnescape(path) - if err != nil { - logger.Error("failed to decode path", "error", err) - decodedPath = path - } - w.WriteHeader(http.StatusNotFound) - app.NewTemplatingEngine(). - Set("title", "Not found"). - Set("path", decodedPath). - Set("Config", cfg). - Render(w, r, "404.html", "funcs.html") -} - -func writeError(logger *slog.Logger, w http.ResponseWriter, err error) { - if details := errors.Unwrap(err); details != nil { - logger.Error("handler", "error", err, "details", details) - } else { - logger.Error("handler", "error", err) - } - - // XXX: writeError should return an error page template. - w.WriteHeader(500) - w.Write([]byte(err.Error())) -} - -func pathOf(diruri string) string { - parts := strings.Split(diruri, "/") - if parts[0] == "gno.land" { - return "/" + strings.Join(parts[1:], "/") - } - - panic(fmt.Sprintf("invalid dir-URI %q", diruri)) -} - -// parseQueryArgs parses URL query arguments that are not specific to gnoweb. -// These are the standard arguments that comes after the "?" symbol and before -// the special "$" symbol. The "$" symbol can be used within public query -// arguments by using its encoded representation "%24". -func parseQueryArgs(rawURL string) (url.Values, error) { - if i := strings.Index(rawURL, gnowebArgsSeparator); i != -1 { - rawURL = rawURL[:i] - } - - u, err := url.Parse(rawURL) - if err != nil { - return url.Values{}, fmt.Errorf("invalid query arguments: %w", err) - } - return u.Query(), nil -} - -// parseGnowebArgs parses URL query arguments that are specific to gnoweb. -// These arguments are indicated by using the "$" symbol followed by a query -// string with the arguments. -func parseGnowebArgs(rawURL string) (url.Values, error) { - i := strings.Index(rawURL, gnowebArgsSeparator) - if i == -1 { - return url.Values{}, nil - } - - values, err := url.ParseQuery(rawURL[i+1:]) - if err != nil { - return url.Values{}, fmt.Errorf("invalid gnoweb arguments: %w", err) - } - return values, nil -} diff --git a/gno.land/pkg/gnoweb/handler.go b/gno.land/pkg/gnoweb/handler.go new file mode 100644 index 00000000000..2dc51d64029 --- /dev/null +++ b/gno.land/pkg/gnoweb/handler.go @@ -0,0 +1,347 @@ +package gnoweb + +import ( + "bytes" + "errors" + "fmt" + "html/template" + "io" + "log/slog" + "net/http" + "path/filepath" + "strings" + "time" + + "github.com/gnolang/gno/gno.land/pkg/gnoweb/components" + "github.com/gnolang/gno/gno.land/pkg/sdk/vm" // For error types +) + +// StaticMetadata holds static configuration for a web handler. +type StaticMetadata struct { + Domain string + AssetsPath string + ChromaPath string + RemoteHelp string + ChainId string + Analytics bool +} + +// WebHandlerConfig configures a WebHandler. +type WebHandlerConfig struct { + Meta StaticMetadata + WebClient WebClient +} + +// validate checks if the WebHandlerConfig is valid. +func (cfg WebHandlerConfig) validate() error { + if cfg.WebClient == nil { + return errors.New("no `WebClient` configured") + } + return nil +} + +// WebHandler processes HTTP requests. +type WebHandler struct { + Logger *slog.Logger + Static StaticMetadata + Client WebClient +} + +// NewWebHandler creates a new WebHandler. +func NewWebHandler(logger *slog.Logger, cfg WebHandlerConfig) (*WebHandler, error) { + if err := cfg.validate(); err != nil { + return nil, fmt.Errorf("config validate error: %w", err) + } + + return &WebHandler{ + Client: cfg.WebClient, + Static: cfg.Meta, + Logger: logger, + }, nil +} + +// ServeHTTP handles HTTP requests. +func (h *WebHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + h.Logger.Debug("receiving request", "method", r.Method, "path", r.URL.Path) + + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + h.Get(w, r) +} + +// Get processes a GET HTTP request. +func (h *WebHandler) Get(w http.ResponseWriter, r *http.Request) { + var body bytes.Buffer + + start := time.Now() + defer func() { + h.Logger.Debug("request completed", + "url", r.URL.String(), + "elapsed", time.Since(start).String()) + }() + + indexData := components.IndexData{ + HeadData: components.HeadData{ + AssetsPath: h.Static.AssetsPath, + ChromaPath: h.Static.ChromaPath, + }, + FooterData: components.FooterData{ + Analytics: h.Static.Analytics, + AssetsPath: h.Static.AssetsPath, + }, + } + + status, err := h.renderPage(&body, r, &indexData) + if err != nil { + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + + w.WriteHeader(status) + + // NOTE: HTML escaping should have already been done by markdown rendering package + indexData.Body = template.HTML(body.String()) //nolint:gosec + + // Render the final page with the rendered body + if err = components.RenderIndexComponent(w, indexData); err != nil { + h.Logger.Error("failed to render index component", "err", err) + } +} + +// renderPage renders the page into the given buffer and prepares the index data. +func (h *WebHandler) renderPage(body *bytes.Buffer, r *http.Request, indexData *components.IndexData) (int, error) { + gnourl, err := ParseGnoURL(r.URL) + if err != nil { + h.Logger.Warn("unable to parse url path", "path", r.URL.Path, "err", err) + return http.StatusNotFound, components.RenderStatusComponent(body, "invalid path") + } + + breadcrumb := generateBreadcrumbPaths(gnourl) + indexData.HeadData.Title = h.Static.Domain + " - " + gnourl.Path + indexData.HeaderData = components.HeaderData{ + RealmPath: gnourl.Encode(EncodePath | EncodeArgs | EncodeQuery | EncodeNoEscape), + Breadcrumb: breadcrumb, + WebQuery: gnourl.WebQuery, + } + + switch { + case gnourl.IsRealm(), gnourl.IsPure(): + return h.GetPackagePage(body, gnourl) + default: + h.Logger.Debug("invalid path: path is neither a pure package or a realm") + return http.StatusBadRequest, components.RenderStatusComponent(body, "invalid path") + } +} + +// GetPackagePage handles package pages. +func (h *WebHandler) GetPackagePage(w io.Writer, gnourl *GnoURL) (int, error) { + h.Logger.Info("component render", "path", gnourl.Path, "args", gnourl.Args) + + // Handle Help page + if gnourl.WebQuery.Has("help") { + return h.GetHelpPage(w, gnourl) + } + + // Handle Source page + if gnourl.WebQuery.Has("source") || gnourl.IsFile() { + return h.GetSourcePage(w, gnourl) + } + + // Handle Source page + if gnourl.IsDir() || gnourl.IsPure() { + return h.GetDirectoryPage(w, gnourl) + } + + // Ultimately render realm content + return h.renderRealmContent(w, gnourl) +} + +// renderRealmContent renders the content of a realm. +func (h *WebHandler) renderRealmContent(w io.Writer, gnourl *GnoURL) (int, error) { + var content bytes.Buffer + meta, err := h.Client.RenderRealm(&content, gnourl.Path, gnourl.EncodeArgs()) + if err != nil { + h.Logger.Error("unable to render realm", "err", err, "path", gnourl.EncodeArgs()) + return renderClientErrorStatusPage(w, gnourl, err) + } + + err = components.RenderRealmComponent(w, components.RealmData{ + TocItems: &components.RealmTOCData{ + Items: meta.Toc.Items, + }, + // NOTE: `RenderRealm` should ensure that HTML content is + // sanitized before rendering + Content: template.HTML(content.String()), //nolint:gosec + }) + if err != nil { + h.Logger.Error("unable to render template", "err", err) + return http.StatusInternalServerError, components.RenderStatusComponent(w, "internal error") + } + + return http.StatusOK, nil +} + +// GetHelpPage renders the help page. +func (h *WebHandler) GetHelpPage(w io.Writer, gnourl *GnoURL) (int, error) { + fsigs, err := h.Client.Functions(gnourl.Path) + if err != nil { + h.Logger.Error("unable to fetch path functions", "err", err) + return renderClientErrorStatusPage(w, gnourl, err) + } + + selArgs := make(map[string]string) + selFn := gnourl.WebQuery.Get("func") + if selFn != "" { + for _, fn := range fsigs { + if selFn != fn.FuncName { + continue + } + + for _, param := range fn.Params { + selArgs[param.Name] = gnourl.WebQuery.Get(param.Name) + } + + fsigs = []vm.FunctionSignature{fn} + break + } + } + + realmName := filepath.Base(gnourl.Path) + err = components.RenderHelpComponent(w, components.HelpData{ + SelectedFunc: selFn, + SelectedArgs: selArgs, + RealmName: realmName, + ChainId: h.Static.ChainId, + // TODO: get chain domain and use that. + PkgPath: filepath.Join(h.Static.Domain, gnourl.Path), + Remote: h.Static.RemoteHelp, + Functions: fsigs, + }) + if err != nil { + h.Logger.Error("unable to render helper", "err", err) + return http.StatusInternalServerError, components.RenderStatusComponent(w, "internal error") + } + + return http.StatusOK, nil +} + +// GetSource renders the source page. +func (h *WebHandler) GetSourcePage(w io.Writer, gnourl *GnoURL) (int, error) { + pkgPath := gnourl.Path + files, err := h.Client.Sources(pkgPath) + if err != nil { + h.Logger.Error("unable to list sources file", "path", gnourl.Path, "err", err) + return renderClientErrorStatusPage(w, gnourl, err) + } + + if len(files) == 0 { + h.Logger.Debug("no files available", "path", gnourl.Path) + return http.StatusOK, components.RenderStatusComponent(w, "no files available") + } + + var fileName string + if gnourl.IsFile() { // check path file from path first + fileName = gnourl.File + } else if file := gnourl.WebQuery.Get("file"); file != "" { + fileName = file + } + + if fileName == "" { + fileName = files[0] // fallback on the first file if + } + + var source bytes.Buffer + meta, err := h.Client.SourceFile(&source, pkgPath, fileName) + if err != nil { + h.Logger.Error("unable to get source file", "file", fileName, "err", err) + return renderClientErrorStatusPage(w, gnourl, err) + } + + fileSizeStr := fmt.Sprintf("%.2f Kb", meta.SizeKb) + err = components.RenderSourceComponent(w, components.SourceData{ + PkgPath: gnourl.Path, + Files: files, + FileName: fileName, + FileCounter: len(files), + FileLines: meta.Lines, + FileSize: fileSizeStr, + FileSource: template.HTML(source.String()), //nolint:gosec + }) + if err != nil { + h.Logger.Error("unable to render helper", "err", err) + return http.StatusInternalServerError, components.RenderStatusComponent(w, "internal error") + } + + return http.StatusOK, nil +} + +// GetDirectoryPage renders the directory page. +func (h *WebHandler) GetDirectoryPage(w io.Writer, gnourl *GnoURL) (int, error) { + pkgPath := strings.TrimSuffix(gnourl.Path, "/") + + files, err := h.Client.Sources(pkgPath) + if err != nil { + h.Logger.Error("unable to list sources file", "path", gnourl.Path, "err", err) + return renderClientErrorStatusPage(w, gnourl, err) + } + + if len(files) == 0 { + h.Logger.Debug("no files available", "path", gnourl.Path) + return http.StatusOK, components.RenderStatusComponent(w, "no files available") + } + + err = components.RenderDirectoryComponent(w, components.DirData{ + PkgPath: gnourl.Path, + Files: files, + FileCounter: len(files), + }) + if err != nil { + h.Logger.Error("unable to render directory", "err", err) + return http.StatusInternalServerError, components.RenderStatusComponent(w, "not found") + } + + return http.StatusOK, nil +} + +func renderClientErrorStatusPage(w io.Writer, _ *GnoURL, err error) (int, error) { + if err == nil { + return http.StatusOK, nil + } + + switch { + case errors.Is(err, ErrClientPathNotFound): + return http.StatusNotFound, components.RenderStatusComponent(w, err.Error()) + case errors.Is(err, ErrClientBadRequest): + return http.StatusInternalServerError, components.RenderStatusComponent(w, "bad request") + case errors.Is(err, ErrClientResponse): + fallthrough // XXX: for now fallback as internal error + default: + return http.StatusInternalServerError, components.RenderStatusComponent(w, "internal error") + } +} + +func generateBreadcrumbPaths(url *GnoURL) components.BreadcrumbData { + split := strings.Split(url.Path, "/") + + var data components.BreadcrumbData + var name string + for i := range split { + if name = split[i]; name == "" { + continue + } + + data.Parts = append(data.Parts, components.BreadcrumbPart{ + Name: name, + URL: strings.Join(split[:i+1], "/"), + }) + } + + if args := url.EncodeArgs(); args != "" { + data.Args = args + } + + return data +} diff --git a/gno.land/pkg/gnoweb/handler_test.go b/gno.land/pkg/gnoweb/handler_test.go new file mode 100644 index 00000000000..624e3390a97 --- /dev/null +++ b/gno.land/pkg/gnoweb/handler_test.go @@ -0,0 +1,112 @@ +package gnoweb_test + +import ( + "log/slog" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gnolang/gno/gno.land/pkg/gnoweb" + "github.com/gnolang/gno/gno.land/pkg/sdk/vm" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type testingLogger struct { + *testing.T +} + +func (t *testingLogger) Write(b []byte) (n int, err error) { + t.T.Log(strings.TrimSpace(string(b))) + return len(b), nil +} + +// TestWebHandler_Get tests the Get method of WebHandler using table-driven tests. +func TestWebHandler_Get(t *testing.T) { + // Set up a mock package with some files and functions + mockPackage := &gnoweb.MockPackage{ + Domain: "example.com", + Path: "/r/mock/path", + Files: map[string]string{ + "render.gno": `package main; func Render(path string) { return "one more time" }`, + "gno.mod": `module example.com/r/mock/path`, + "LicEnse": `my super license`, + }, + Functions: []vm.FunctionSignature{ + {FuncName: "SuperRenderFunction", Params: []vm.NamedType{ + {Name: "my_super_arg", Type: "string"}, + }}, + }, + } + + // Create a mock web client with the mock package + webclient := gnoweb.NewMockWebClient(mockPackage) + + // Create a WebHandlerConfig with the mock web client and static metadata + config := gnoweb.WebHandlerConfig{ + WebClient: webclient, + } + + // Define test cases + cases := []struct { + Path string + Status int + Contain string // optional + Contains []string // optional + }{ + // Found + {Path: "/r/mock/path", Status: http.StatusOK, Contain: "[example.com]/r/mock/path"}, + + // Source page + {Path: "/r/mock/path/", Status: http.StatusOK, Contain: "Directory"}, + {Path: "/r/mock/path/render.gno", Status: http.StatusOK, Contain: "one more time"}, + {Path: "/r/mock/path/LicEnse", Status: http.StatusOK, Contain: "my super license"}, + {Path: "/r/mock/path$source&file=render.gno", Status: http.StatusOK, Contain: "one more time"}, + {Path: "/r/mock/path$source", Status: http.StatusOK, Contain: "module"}, // `gno.mod` by default + {Path: "/r/mock/path/license", Status: http.StatusNotFound}, + + // Help page + {Path: "/r/mock/path$help", Status: http.StatusOK, Contains: []string{ + "my_super_arg", + "SuperRenderFunction", + }}, + + // Package not found + {Path: "/r/invalid/path", Status: http.StatusNotFound, Contain: "not found"}, + + // Invalid path + {Path: "/r", Status: http.StatusBadRequest, Contain: "invalid path"}, + {Path: "/r/~!1337", Status: http.StatusNotFound, Contain: "invalid path"}, + } + + for _, tc := range cases { + t.Run(strings.TrimPrefix(tc.Path, "/"), func(t *testing.T) { + t.Logf("input: %+v", tc) + + // Initialize testing logger + logger := slog.New(slog.NewTextHandler(&testingLogger{t}, &slog.HandlerOptions{})) + + // Create a new WebHandler + handler, err := gnoweb.NewWebHandler(logger, config) + require.NoError(t, err) + + // Create a new HTTP request for each test case + req, err := http.NewRequest(http.MethodGet, tc.Path, nil) + require.NoError(t, err) + + // Create a ResponseRecorder to capture the response + rr := httptest.NewRecorder() + + // Invoke serve method + handler.ServeHTTP(rr, req) + + // Assert result + assert.Equal(t, tc.Status, rr.Code) + assert.Containsf(t, rr.Body.String(), tc.Contain, "rendered body should contain: %q", tc.Contain) + for _, contain := range tc.Contains { + assert.Containsf(t, rr.Body.String(), contain, "rendered body should contain: %q", contain) + } + }) + } +} diff --git a/gno.land/pkg/gnoweb/markdown/toc.go b/gno.land/pkg/gnoweb/markdown/toc.go new file mode 100644 index 00000000000..ceafbd7cc96 --- /dev/null +++ b/gno.land/pkg/gnoweb/markdown/toc.go @@ -0,0 +1,137 @@ +// This file is a minimal version of https://github.com/abhinav/goldmark-toc + +package markdown + +import ( + "github.com/yuin/goldmark/ast" + "github.com/yuin/goldmark/util" +) + +const MaxDepth = 6 + +type Toc struct { + Items []*TocItem +} + +type TocItem struct { + // Title of this item in the table of contents. + // + // This may be blank for items that don't refer to a heading, and only + // have sub-items. + Title []byte + + // ID is the identifier for the heading that this item refers to. This + // is the fragment portion of the link without the "#". + // + // This may be blank if the item doesn't have an id assigned to it, or + // if it doesn't have a title. + // + // Enable AutoHeadingID in your parser if you expected these to be set + // but they weren't. + ID []byte + + // Items references children of this item. + // + // For a heading at level 3, Items, contains the headings at level 4 + // under that section. + Items []*TocItem +} + +func (i TocItem) Anchor() string { + return "#" + string(i.ID) +} + +type TocOptions struct { + MinDepth, MaxDepth int +} + +func TocInspect(n ast.Node, src []byte, opts TocOptions) (Toc, error) { + // Appends an empty subitem to the given node + // and returns a reference to it. + appendChild := func(n *TocItem) *TocItem { + child := new(TocItem) + n.Items = append(n.Items, child) + return child + } + + // Returns the last subitem of the given node, + // creating it if necessary. + lastChild := func(n *TocItem) *TocItem { + if len(n.Items) > 0 { + return n.Items[len(n.Items)-1] + } + return appendChild(n) + } + + var root TocItem + + stack := []*TocItem{&root} // inv: len(stack) >= 1 + err := ast.Walk(n, func(n ast.Node, entering bool) (ast.WalkStatus, error) { + if !entering { + return ast.WalkContinue, nil + } + + // Skip non-heading node + heading, ok := n.(*ast.Heading) + if !ok { + return ast.WalkContinue, nil + } + + if opts.MinDepth > 0 && heading.Level < opts.MinDepth { + return ast.WalkSkipChildren, nil + } + + if opts.MaxDepth > 0 && heading.Level > opts.MaxDepth { + return ast.WalkSkipChildren, nil + } + + // The heading is deeper than the current depth. + // Append empty items to match the heading's level. + for len(stack) < heading.Level { + parent := stack[len(stack)-1] + stack = append(stack, lastChild(parent)) + } + + // The heading is shallower than the current depth. + // Move back up the stack until we reach the heading's level. + if len(stack) > heading.Level { + stack = stack[:heading.Level] + } + + parent := stack[len(stack)-1] + target := lastChild(parent) + if len(target.Title) > 0 || len(target.Items) > 0 { + target = appendChild(parent) + } + + target.Title = util.UnescapePunctuations(heading.Text(src)) + if id, ok := n.AttributeString("id"); ok { + target.ID, _ = id.([]byte) + } + + return ast.WalkSkipChildren, nil + }) + + root.Items = compactItems(root.Items) + + return Toc{Items: root.Items}, err +} + +// compactItems removes items with no titles +// from the given list of items. +// +// Children of removed items will be promoted to the parent item. +func compactItems(items []*TocItem) []*TocItem { + result := make([]*TocItem, 0) + for _, item := range items { + if len(item.Title) == 0 { + result = append(result, compactItems(item.Items)...) + continue + } + + item.Items = compactItems(item.Items) + result = append(result, item) + } + + return result +} diff --git a/gno.land/pkg/gnoweb/public/favicon.ico b/gno.land/pkg/gnoweb/public/favicon.ico new file mode 100644 index 00000000000..528c362c44a Binary files /dev/null and b/gno.land/pkg/gnoweb/public/favicon.ico differ diff --git a/gno.land/pkg/gnoweb/public/fonts/intervar/Intervar.woff2 b/gno.land/pkg/gnoweb/public/fonts/intervar/Intervar.woff2 new file mode 100644 index 00000000000..891fc5cc567 Binary files /dev/null and b/gno.land/pkg/gnoweb/public/fonts/intervar/Intervar.woff2 differ diff --git a/gno.land/pkg/gnoweb/public/fonts/roboto/roboto-mono-normal.woff b/gno.land/pkg/gnoweb/public/fonts/roboto/roboto-mono-normal.woff new file mode 100644 index 00000000000..2c58fe2d6d7 Binary files /dev/null and b/gno.land/pkg/gnoweb/public/fonts/roboto/roboto-mono-normal.woff differ diff --git a/gno.land/pkg/gnoweb/public/fonts/roboto/roboto-mono-normal.woff2 b/gno.land/pkg/gnoweb/public/fonts/roboto/roboto-mono-normal.woff2 new file mode 100644 index 00000000000..53d081f3a53 Binary files /dev/null and b/gno.land/pkg/gnoweb/public/fonts/roboto/roboto-mono-normal.woff2 differ diff --git a/gno.land/pkg/gnoweb/public/imgs/gnoland.svg b/gno.land/pkg/gnoweb/public/imgs/gnoland.svg new file mode 100644 index 00000000000..30d2f3ef56a --- /dev/null +++ b/gno.land/pkg/gnoweb/public/imgs/gnoland.svg @@ -0,0 +1,4 @@ + + + + diff --git a/gno.land/pkg/gnoweb/public/js/copy.js b/gno.land/pkg/gnoweb/public/js/copy.js new file mode 100644 index 00000000000..918a30b1ca3 --- /dev/null +++ b/gno.land/pkg/gnoweb/public/js/copy.js @@ -0,0 +1 @@ +var s=class o{DOM;static FEEDBACK_DELAY=750;btnClicked=null;btnClickedIcons=[];isAnimationRunning=!1;static SELECTORS={button:"[data-copy-btn]",icon:"[data-copy-icon] > use",content:t=>`[data-copy-content="${t}"]`};constructor(){this.DOM={el:document.querySelector("main")},this.DOM.el?this.init():console.warn("Copy: Main container not found.")}init(){this.bindEvents()}bindEvents(){this.DOM.el?.addEventListener("click",this.handleClick.bind(this))}handleClick(t){let e=t.target.closest(o.SELECTORS.button);if(!e)return;this.btnClicked=e,this.btnClickedIcons=Array.from(e.querySelectorAll(o.SELECTORS.icon));let i=e.getAttribute("data-copy-btn");if(!i){console.warn("Copy: No content ID found on the button.");return}let r=this.DOM.el?.querySelector(o.SELECTORS.content(i));r?this.copyToClipboard(r,this.btnClickedIcons):console.warn(`Copy: No content found for ID "${i}".`)}sanitizeContent(t){let n=t.innerHTML.replace(/]*class="chroma-ln"[^>]*>[\s\S]*?<\/span>/g,""),e=document.createElement("div");return e.innerHTML=n,e.textContent?.trim()||""}toggleIcons(t){t.forEach(n=>{n.classList.toggle("hidden")})}showFeedback(t){!this.btnClicked||this.isAnimationRunning===!0||(this.isAnimationRunning=!0,this.toggleIcons(t),window.setTimeout(()=>{this.toggleIcons(t),this.isAnimationRunning=!1},o.FEEDBACK_DELAY))}async copyToClipboard(t,n){let e=this.sanitizeContent(t);if(!navigator.clipboard){console.error("Copy: Clipboard API is not supported in this browser."),this.showFeedback(n);return}try{await navigator.clipboard.writeText(e),this.showFeedback(n)}catch(i){console.error("Copy: Error while copying text.",i),this.showFeedback(n)}}},a=()=>new s;export{a as default}; diff --git a/gno.land/pkg/gnoweb/public/js/index.js b/gno.land/pkg/gnoweb/public/js/index.js new file mode 100644 index 00000000000..e990dd91f5f --- /dev/null +++ b/gno.land/pkg/gnoweb/public/js/index.js @@ -0,0 +1 @@ +(()=>{let s={copy:{selector:"[data-copy-btn]",path:"/public/js/copy.js"},help:{selector:"#help",path:"/public/js/realmhelp.js"},searchBar:{selector:"#header-searchbar",path:"/public/js/searchbar.js"}},r=async({selector:e,path:o})=>{if(document.querySelector(e))try{(await import(o)).default()}catch(t){console.error(`Error while loading script ${o}:`,t)}else console.warn(`Module not loaded: no element matches selector "${e}"`)},l=async()=>{let e=Object.values(s).map(o=>r(o));await Promise.all(e)};document.addEventListener("DOMContentLoaded",l)})(); diff --git a/gno.land/pkg/gnoweb/public/js/realmhelp.js b/gno.land/pkg/gnoweb/public/js/realmhelp.js new file mode 100644 index 00000000000..5d4a3feeba6 --- /dev/null +++ b/gno.land/pkg/gnoweb/public/js/realmhelp.js @@ -0,0 +1 @@ +function d(s,e=250){let t;return function(...a){t!==void 0&&clearTimeout(t),t=setTimeout(()=>{s.apply(this,a)},e)}}var l=class s{DOM;funcList;static SELECTORS={container:"#help",func:"[data-func]",addressInput:"[data-role='help-input-addr']",cmdModeSelect:"[data-role='help-select-mode']"};constructor(){this.DOM={el:document.querySelector(s.SELECTORS.container),funcs:[],addressInput:null,cmdModeSelect:null},this.funcList=[],this.DOM.el?this.init():console.warn("Help: Main container not found.")}init(){let{el:e}=this.DOM;e&&(this.DOM.funcs=Array.from(e.querySelectorAll(s.SELECTORS.func)),this.DOM.addressInput=e.querySelector(s.SELECTORS.addressInput),this.DOM.cmdModeSelect=e.querySelector(s.SELECTORS.cmdModeSelect),this.funcList=this.DOM.funcs.map(t=>new o(t)),this.restoreAddress(),this.bindEvents())}restoreAddress(){let{addressInput:e}=this.DOM;if(e){let t=localStorage.getItem("helpAddressInput");t&&(e.value=t,this.funcList.forEach(a=>a.updateAddr(t)))}}bindEvents(){let{addressInput:e,cmdModeSelect:t}=this.DOM,a=d(r=>{let n=r.value;localStorage.setItem("helpAddressInput",n),this.funcList.forEach(i=>i.updateAddr(n))});e?.addEventListener("input",()=>a(e)),t?.addEventListener("change",r=>{let n=r.target;this.funcList.forEach(i=>i.updateMode(n.value))})}},o=class s{DOM;funcName;static SELECTORS={address:"[data-role='help-code-address']",args:"[data-role='help-code-args']",mode:"[data-code-mode]",paramInput:"[data-role='help-param-input']"};constructor(e){this.DOM={el:e,addrs:Array.from(e.querySelectorAll(s.SELECTORS.address)),args:Array.from(e.querySelectorAll(s.SELECTORS.args)),modes:Array.from(e.querySelectorAll(s.SELECTORS.mode)),paramInputs:Array.from(e.querySelectorAll(s.SELECTORS.paramInput))},this.funcName=e.dataset.func||null,this.initializeArgs(),this.bindEvents()}static sanitizeArgsInput(e){let t=e.dataset.param||"",a=e.value.trim();return t||console.warn("sanitizeArgsInput: param is missing in arg input dataset."),{paramName:t,paramValue:a}}bindEvents(){let e=d((t,a)=>{t&&this.updateArg(t,a)});this.DOM.el.addEventListener("input",t=>{let a=t.target;if(a.dataset.role==="help-param-input"){let{paramName:r,paramValue:n}=s.sanitizeArgsInput(a);e(r,n)}})}initializeArgs(){this.DOM.paramInputs.forEach(e=>{let{paramName:t,paramValue:a}=s.sanitizeArgsInput(e);t&&this.updateArg(t,a)})}updateArg(e,t){this.DOM.args.filter(a=>a.dataset.arg===e).forEach(a=>{a.textContent=t||""})}updateAddr(e){this.DOM.addrs.forEach(t=>{t.textContent=e.trim()||"ADDRESS"})}updateMode(e){this.DOM.modes.forEach(t=>{let a=t.dataset.codeMode===e;t.classList.toggle("inline",a),t.classList.toggle("hidden",!a),t.dataset.copyContent=a?`help-cmd-${this.funcName}`:""})}},p=()=>new l;export{p as default}; diff --git a/gno.land/pkg/gnoweb/public/js/searchbar.js b/gno.land/pkg/gnoweb/public/js/searchbar.js new file mode 100644 index 00000000000..e8012b9b6d9 --- /dev/null +++ b/gno.land/pkg/gnoweb/public/js/searchbar.js @@ -0,0 +1 @@ +var n=class r{DOM;baseUrl;static SELECTORS={container:"#header-searchbar",inputSearch:"[data-role='header-input-search']",breadcrumb:"[data-role='header-breadcrumb-search']"};constructor(){this.DOM={el:document.querySelector(r.SELECTORS.container),inputSearch:null,breadcrumb:null},this.baseUrl=window.location.origin,this.DOM.el?this.init():console.warn("SearchBar: Main container not found.")}init(){let{el:e}=this.DOM;this.DOM.inputSearch=e?.querySelector(r.SELECTORS.inputSearch)??null,this.DOM.breadcrumb=e?.querySelector(r.SELECTORS.breadcrumb)??null,this.DOM.inputSearch||console.warn("SearchBar: Input element for search not found."),this.bindEvents()}bindEvents(){this.DOM.el?.addEventListener("submit",e=>{e.preventDefault(),this.searchUrl()})}searchUrl(){let e=this.DOM.inputSearch?.value.trim();if(e){let t=e;/^https?:\/\//i.test(t)||(t=`${this.baseUrl}${t.startsWith("/")?"":"/"}${t}`);try{window.location.href=new URL(t).href}catch{console.error("SearchBar: Invalid URL. Please enter a valid URL starting with http:// or https://.")}}else console.error("SearchBar: Please enter a URL to search.")}},i=()=>new n;export{i as default}; diff --git a/gno.land/pkg/gnoweb/public/js/utils.js b/gno.land/pkg/gnoweb/public/js/utils.js new file mode 100644 index 00000000000..e27fb93bc1c --- /dev/null +++ b/gno.land/pkg/gnoweb/public/js/utils.js @@ -0,0 +1 @@ +function r(t,n=250){let e;return function(...i){e!==void 0&&clearTimeout(e),e=setTimeout(()=>{t.apply(this,i)},n)}}export{r as debounce}; diff --git a/gno.land/pkg/gnoweb/public/styles.css b/gno.land/pkg/gnoweb/public/styles.css new file mode 100644 index 00000000000..8e8d7ed802d --- /dev/null +++ b/gno.land/pkg/gnoweb/public/styles.css @@ -0,0 +1,3 @@ +@font-face{font-family:Roboto;font-style:normal;font-weight:900;font-display:swap;src:url(fonts/roboto/roboto-mono-normal.woff2) format("woff2"),url(fonts/roboto/roboto-mono-normal.woff) format("woff")}@font-face{font-family:Inter var;font-weight:100 900;font-display:block;font-style:oblique 0deg 10deg;src:url(fonts/intervar/Intervar.woff2) format("woff2")}*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: } + +/*! tailwindcss v3.4.14 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #bdbdbd}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:Roboto,Menlo,Consolas,Ubuntu Mono,Roboto Mono,DejaVu Sans Mono,monospace;;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#7c7c7c}input::placeholder,textarea::placeholder{opacity:1;color:#7c7c7c}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}html{--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity));font-family:Inter var,-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica Neue,Arial,Noto Sans,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji,sans-serif;font-size:1rem;--tw-text-opacity:1;color:rgb(84 89 93/var(--tw-text-opacity));font-feature-settings:"kern" on,"liga" on,"calt" on,"zero" on;-webkit-font-feature-settings:"kern" on,"liga" on,"calt" on,"zero" on;-webkit-text-size-adjust:100%;-moz-text-size-adjust:100%;text-size-adjust:100%;-moz-osx-font-smoothing:grayscale;font-smoothing:antialiased;font-variant-ligatures:contextual common-ligatures;font-kerning:normal;text-rendering:optimizeLegibility}svg{max-height:100%;max-width:100%}form{margin-top:0;margin-bottom:0}.realm-content{overflow-wrap:break-word;padding-top:2.5rem;font-size:1rem}.realm-content>:first-child{margin-top:0!important}.realm-content a{font-weight:500;--tw-text-opacity:1;color:rgb(34 108 87/var(--tw-text-opacity))}.realm-content a:hover{text-decoration-line:underline}.realm-content h1,.realm-content h2,.realm-content h3,.realm-content h4{margin-top:3rem;line-height:1.25;--tw-text-opacity:1;color:rgb(8 8 9/var(--tw-text-opacity))}.realm-content h2,.realm-content h2 *{font-weight:700}.realm-content h3,.realm-content h3 *,.realm-content h4,.realm-content h4 *{font-weight:600}.realm-content h1+h2,.realm-content h2+h3,.realm-content h3+h4{margin-top:1rem}.realm-content h1{font-size:2.375rem;font-weight:700}.realm-content h2{font-size:1.5rem}.realm-content h3{margin-top:2.5rem;font-size:1.25rem}.realm-content h3,.realm-content h4{--tw-text-opacity:1;color:rgb(84 89 93/var(--tw-text-opacity))}.realm-content h4{margin-top:1.5rem;margin-bottom:1.5rem;font-size:1.125rem;font-weight:500}.realm-content p{margin-top:1.25rem;margin-bottom:1.25rem}.realm-content strong{font-weight:700;--tw-text-opacity:1;color:rgb(8 8 9/var(--tw-text-opacity))}.realm-content strong *{font-weight:700}.realm-content em{font-style:oblique 10deg}.realm-content blockquote{margin-top:1rem;margin-bottom:1rem;border-left-width:4px;--tw-border-opacity:1;border-color:rgb(153 153 153/var(--tw-border-opacity));padding-left:1rem;--tw-text-opacity:1;color:rgb(84 89 93/var(--tw-text-opacity));font-style:oblique 10deg}.realm-content ol,.realm-content ul{margin-top:1.5rem;margin-bottom:1.5rem;padding-left:1rem}.realm-content ol li,.realm-content ul li{margin-bottom:.5rem}.realm-content img{margin-top:2rem;margin-bottom:2rem;max-width:100%}.realm-content figure{margin-top:1.5rem;margin-bottom:1.5rem;text-align:center}.realm-content figcaption{font-size:.875rem;--tw-text-opacity:1;color:rgb(84 89 93/var(--tw-text-opacity))}.realm-content :not(pre)>code{border-radius:.25rem;background-color:rgb(226 226 226/var(--tw-bg-opacity));padding:.125rem .25rem;font-size:.96em}.realm-content :not(pre)>code,.realm-content pre{--tw-bg-opacity:1;font-family:Roboto,Menlo,Consolas,Ubuntu Mono,Roboto Mono,DejaVu Sans Mono,monospace;}.realm-content pre{overflow-x:auto;border-radius:.375rem;background-color:rgb(240 240 240/var(--tw-bg-opacity));padding:1rem}.realm-content hr{margin-top:2.5rem;margin-bottom:2.5rem;border-top-width:1px;--tw-border-opacity:1;border-color:rgb(226 226 226/var(--tw-border-opacity))}.realm-content table{margin-top:2rem;margin-bottom:2rem;display:block;width:100%;max-width:100%;border-collapse:collapse;overflow-x:auto}.realm-content td,.realm-content th{white-space:normal;overflow-wrap:break-word;border-width:1px;padding:.5rem 1rem}.realm-content th{--tw-bg-opacity:1;background-color:rgb(226 226 226/var(--tw-bg-opacity));font-weight:700}.realm-content caption{margin-top:.5rem;text-align:left;font-size:.875rem;--tw-text-opacity:1;color:rgb(84 89 93/var(--tw-text-opacity))}.realm-content q{margin-top:1.5rem;margin-bottom:1.5rem;border-left-width:4px;--tw-border-opacity:1;border-left-color:rgb(204 204 204/var(--tw-border-opacity));padding-left:1rem;--tw-text-opacity:1;color:rgb(85 85 85/var(--tw-text-opacity));font-style:oblique 10deg;quotes:"“" "”" "‘" "’"}.realm-content q:after,.realm-content q:before{margin-right:.25rem;font-size:1.5rem;--tw-text-opacity:1;color:rgb(153 153 153/var(--tw-text-opacity));content:open-quote;vertical-align:-.4rem}.realm-content q:after{content:close-quote}.realm-content q:before{content:open-quote}.realm-content q:after{content:close-quote}.realm-content ol ol,.realm-content ol ul,.realm-content ul ol,.realm-content ul ul{margin-top:.75rem;margin-bottom:.5rem;padding-left:1rem}.realm-content ul{list-style-type:disc}.realm-content ol{list-style-type:decimal}.realm-content abbr[title]{cursor:help;border-bottom-width:1px;border-style:dotted}.realm-content details{margin-top:1.25rem;margin-bottom:1.25rem}.realm-content summary{cursor:pointer;font-weight:700}.realm-content a code{color:inherit}.realm-content video{margin-top:2rem;margin-bottom:2rem;max-width:100%}.realm-content math{font-family:Roboto,Menlo,Consolas,Ubuntu Mono,Roboto Mono,DejaVu Sans Mono,monospace;}.realm-content small{font-size:.875rem}.realm-content del{text-decoration-line:line-through}.realm-content sub{vertical-align:sub;font-size:.75rem}.realm-content sup{vertical-align:super;font-size:.75rem}.realm-content button,.realm-content input{border-width:1px;--tw-border-opacity:1;border-color:rgb(153 153 153/var(--tw-border-opacity));padding:.5rem 1rem}main :is(h1,h2,h3,h4){scroll-margin-top:6rem}::-moz-selection{--tw-bg-opacity:1;background-color:rgb(34 108 87/var(--tw-bg-opacity));--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity))}::selection{--tw-bg-opacity:1;background-color:rgb(34 108 87/var(--tw-bg-opacity));--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity))}.sidemenu .peer:checked+label>svg{--tw-text-opacity:1;color:rgb(34 108 87/var(--tw-text-opacity))}.toc-expend-btn:has(#toc-expend:checked)+nav{display:block}.toc-expend-btn:has(#toc-expend:checked) .toc-expend-btn_ico{--tw-rotate:180deg;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.main-header:has(#sidemenu-docs:checked)+main #sidebar #sidebar-docs,.main-header:has(#sidemenu-meta:checked)+main #sidebar #sidebar-meta,.main-header:has(#sidemenu-source:checked)+main #sidebar #sidebar-source,.main-header:has(#sidemenu-summary:checked)+main #sidebar #sidebar-summary{display:block}@media (min-width:40rem){:is(.main-header:has(#sidemenu-source:checked),.main-header:has(#sidemenu-docs:checked),.main-header:has(#sidemenu-meta:checked)) .main-navigation,:is(.main-header:has(#sidemenu-source:checked),.main-header:has(#sidemenu-docs:checked),.main-header:has(#sidemenu-meta:checked))+main .realm-content{grid-column:span 6/span 6}:is(.main-header:has(#sidemenu-source:checked),.main-header:has(#sidemenu-docs:checked),.main-header:has(#sidemenu-meta:checked)) .sidemenu,:is(.main-header:has(#sidemenu-source:checked),.main-header:has(#sidemenu-docs:checked),.main-header:has(#sidemenu-meta:checked))+main #sidebar{grid-column:span 4/span 4}}:is(.main-header:has(#sidemenu-source:checked),.main-header:has(#sidemenu-docs:checked),.main-header:has(#sidemenu-meta:checked))+main #sidebar:before{position:absolute;top:0;left:-1.75rem;z-index:-1;display:block;height:100%;width:50vw;--tw-bg-opacity:1;background-color:rgb(226 226 226/var(--tw-bg-opacity));--tw-content:"";content:var(--tw-content)}main :is(.source-code)>pre{overflow:scroll;border-radius:.375rem;--tw-bg-opacity:1!important;background-color:rgb(255 255 255/var(--tw-bg-opacity))!important;padding:1rem .25rem;font-family:Roboto,Menlo,Consolas,Ubuntu Mono,Roboto Mono,DejaVu Sans Mono,monospace;;font-size:.875rem}@media (min-width:40rem){main :is(.source-code)>pre{padding:2rem .75rem;font-size:1rem}}main .realm-content>pre a:hover{text-decoration-line:none}main :is(.realm-content,.source-code)>pre .chroma-ln:target{background-color:transparent!important}main :is(.realm-content,.source-code)>pre .chroma-line:has(.chroma-ln:target),main :is(.realm-content,.source-code)>pre .chroma-line:has(.chroma-ln:target) .chroma-cl,main :is(.realm-content,.source-code)>pre .chroma-line:has(.chroma-lnlinks:hover),main :is(.realm-content,.source-code)>pre .chroma-line:has(.chroma-lnlinks:hover) .chroma-cl{border-radius:.375rem;--tw-bg-opacity:1!important;background-color:rgb(226 226 226/var(--tw-bg-opacity))!important}main :is(.realm-content,.source-code)>pre .chroma-ln{scroll-margin-top:6rem}.absolute{position:absolute}.relative{position:relative}.sticky{position:sticky}.bottom-1{bottom:.25rem}.left-0{left:0}.right-2{right:.5rem}.right-3{right:.75rem}.top-0{top:0}.top-1\/2{top:50%}.top-14{top:3.5rem}.top-2{top:.5rem}.z-1{z-index:1}.z-max{z-index:9999}.col-span-1{grid-column:span 1/span 1}.col-span-10{grid-column:span 10/span 10}.col-span-3{grid-column:span 3/span 3}.col-span-7{grid-column:span 7/span 7}.row-span-1{grid-row:span 1/span 1}.row-start-1{grid-row-start:1}.mx-auto{margin-left:auto;margin-right:auto}.mb-1{margin-bottom:.25rem}.mb-2{margin-bottom:.5rem}.mb-3{margin-bottom:.75rem}.mb-4{margin-bottom:1rem}.mb-8{margin-bottom:2rem}.mr-10{margin-right:2.5rem}.mt-1{margin-top:.25rem}.mt-10{margin-top:2.5rem}.mt-2{margin-top:.5rem}.mt-4{margin-top:1rem}.mt-6{margin-top:1.5rem}.mt-8{margin-top:2rem}.line-clamp-2{overflow:hidden;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2}.block{display:block}.inline-block{display:inline-block}.inline{display:inline}.flex{display:flex}.grid{display:grid}.hidden{display:none}.h-10{height:2.5rem}.h-4{height:1rem}.h-5{height:1.25rem}.h-6{height:1.5rem}.h-full{height:100%}.max-h-screen{max-height:100vh}.min-h-full{min-height:100%}.min-h-screen{min-height:100vh}.w-10{width:2.5rem}.w-4{width:1rem}.w-5{width:1.25rem}.w-full{width:100%}.min-w-2{min-width:.5rem}.min-w-48{min-width:12rem}.max-w-screen-max{max-width:98.75rem}.shrink-0{flex-shrink:0}.grow-\[2\]{flex-grow:2}.-translate-y-1\/2{--tw-translate-y:-50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.cursor-pointer{cursor:pointer}.list-none{list-style-type:none}.appearance-none{-webkit-appearance:none;-moz-appearance:none;appearance:none}.grid-flow-dense{grid-auto-flow:dense}.auto-rows-min{grid-auto-rows:min-content}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-10{grid-template-columns:repeat(10,minmax(0,1fr))}.flex-col{flex-direction:column}.items-start{align-items:flex-start}.items-center{align-items:center}.items-stretch{align-items:stretch}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-0\.5{gap:.125rem}.gap-1{gap:.25rem}.gap-1\.5{gap:.375rem}.gap-2{gap:.5rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.gap-8{gap:2rem}.gap-x-20{-moz-column-gap:5rem;column-gap:5rem}.gap-x-3{-moz-column-gap:.75rem;column-gap:.75rem}.gap-y-2{row-gap:.5rem}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.5rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.5rem*var(--tw-space-y-reverse))}.overflow-hidden{overflow:hidden}.overflow-scroll{overflow:scroll}.whitespace-nowrap{white-space:nowrap}.whitespace-pre-wrap{white-space:pre-wrap}.rounded{border-radius:.375rem}.rounded-sm{border-radius:.25rem}.border{border-width:1px}.border-b{border-bottom-width:1px}.border-l{border-left-width:1px}.border-t{border-top-width:1px}.border-gray-100{--tw-border-opacity:1;border-color:rgb(226 226 226/var(--tw-border-opacity))}.bg-gray-100{--tw-bg-opacity:1;background-color:rgb(226 226 226/var(--tw-bg-opacity))}.bg-gray-300{--tw-bg-opacity:1;background-color:rgb(153 153 153/var(--tw-bg-opacity))}.bg-gray-50{--tw-bg-opacity:1;background-color:rgb(240 240 240/var(--tw-bg-opacity))}.bg-light{--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity))}.bg-transparent{background-color:transparent}.p-1\.5{padding:.375rem}.p-2{padding:.5rem}.p-4{padding:1rem}.px-1{padding-left:.25rem;padding-right:.25rem}.px-10{padding-left:2.5rem;padding-right:2.5rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.py-1\.5{padding-top:.375rem;padding-bottom:.375rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-px{padding-top:1px;padding-bottom:1px}.pb-24{padding-bottom:6rem}.pb-3{padding-bottom:.75rem}.pb-4{padding-bottom:1rem}.pb-6{padding-bottom:1.5rem}.pb-8{padding-bottom:2rem}.pl-4{padding-left:1rem}.pr-10{padding-right:2.5rem}.pt-0\.5{padding-top:.125rem}.pt-2{padding-top:.5rem}.font-mono{font-family:Roboto,Menlo,Consolas,Ubuntu Mono,Roboto Mono,DejaVu Sans Mono,monospace;}.text-100{font-size:.875rem}.text-200{font-size:1rem}.text-50{font-size:.75rem}.text-600{font-size:1.5rem}.font-bold{font-weight:700}.font-medium{font-weight:500}.font-normal{font-weight:400}.font-semibold{font-weight:600}.capitalize{text-transform:capitalize}.leading-tight{line-height:1.25}.text-gray-300{--tw-text-opacity:1;color:rgb(153 153 153/var(--tw-text-opacity))}.text-gray-400{--tw-text-opacity:1;color:rgb(124 124 124/var(--tw-text-opacity))}.text-gray-600{--tw-text-opacity:1;color:rgb(84 89 93/var(--tw-text-opacity))}.text-gray-800{--tw-text-opacity:1;color:rgb(19 19 19/var(--tw-text-opacity))}.text-gray-900{--tw-text-opacity:1;color:rgb(8 8 9/var(--tw-text-opacity))}.text-green-600{--tw-text-opacity:1;color:rgb(34 108 87/var(--tw-text-opacity))}.text-light{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity))}.outline-none{outline:2px solid transparent;outline-offset:2px}.text-stroke{-webkit-text-stroke:currentColor;-webkit-text-stroke-width:.6px}.no-scrollbar::-webkit-scrollbar{display:none}.no-scrollbar{-ms-overflow-style:none;scrollbar-width:none}.\*\:pl-0>*{padding-left:0}.before\:px-\[0\.18rem\]:before{content:var(--tw-content);padding-left:.18rem;padding-right:.18rem}.before\:text-gray-300:before{content:var(--tw-content);--tw-text-opacity:1;color:rgb(153 153 153/var(--tw-text-opacity))}.before\:content-\[\'\/\'\]:before{--tw-content:"/";content:var(--tw-content)}.before\:content-\[\'\:\'\]:before{--tw-content:":";content:var(--tw-content)}.before\:content-\[\'open\'\]:before{--tw-content:"open";content:var(--tw-content)}.after\:pointer-events-none:after{content:var(--tw-content);pointer-events:none}.after\:absolute:after{content:var(--tw-content);position:absolute}.after\:bottom-0:after{content:var(--tw-content);bottom:0}.after\:left-0:after{content:var(--tw-content);left:0}.after\:top-0:after{content:var(--tw-content);top:0}.after\:block:after{content:var(--tw-content);display:block}.after\:h-1:after{content:var(--tw-content);height:.25rem}.after\:h-full:after{content:var(--tw-content);height:100%}.after\:w-full:after{content:var(--tw-content);width:100%}.after\:rounded-t-sm:after{content:var(--tw-content);border-top-left-radius:.25rem;border-top-right-radius:.25rem}.after\:bg-gray-100:after{content:var(--tw-content);--tw-bg-opacity:1;background-color:rgb(226 226 226/var(--tw-bg-opacity))}.after\:bg-green-600:after{content:var(--tw-content);--tw-bg-opacity:1;background-color:rgb(34 108 87/var(--tw-bg-opacity))}.first\:border-t:first-child{border-top-width:1px}.hover\:border-gray-300:hover{--tw-border-opacity:1;border-color:rgb(153 153 153/var(--tw-border-opacity))}.hover\:bg-gray-100:hover{--tw-bg-opacity:1;background-color:rgb(226 226 226/var(--tw-bg-opacity))}.hover\:bg-gray-50:hover{--tw-bg-opacity:1;background-color:rgb(240 240 240/var(--tw-bg-opacity))}.hover\:bg-green-600:hover{--tw-bg-opacity:1;background-color:rgb(34 108 87/var(--tw-bg-opacity))}.hover\:text-gray-600:hover{--tw-text-opacity:1;color:rgb(84 89 93/var(--tw-text-opacity))}.hover\:text-green-600:hover{--tw-text-opacity:1;color:rgb(34 108 87/var(--tw-text-opacity))}.hover\:text-light:hover{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity))}.hover\:underline:hover{text-decoration-line:underline}.focus\:border-gray-300:focus{--tw-border-opacity:1;border-color:rgb(153 153 153/var(--tw-border-opacity))}.focus\:border-l-gray-300:focus{--tw-border-opacity:1;border-left-color:rgb(153 153 153/var(--tw-border-opacity))}.group:hover .group-hover\:border-gray-300{--tw-border-opacity:1;border-color:rgb(153 153 153/var(--tw-border-opacity))}.group:hover .group-hover\:border-l-gray-300{--tw-border-opacity:1;border-left-color:rgb(153 153 153/var(--tw-border-opacity))}.group.is-active .group-\[\.is-active\]\:text-green-600{--tw-text-opacity:1;color:rgb(34 108 87/var(--tw-text-opacity))}.peer:checked~.peer-checked\:before\:content-\[\'close\'\]:before{--tw-content:"close";content:var(--tw-content)}.peer:focus-within~.peer-focus-within\:hidden{display:none}.has-\[ul\:empty\]\:hidden:has(ul:empty){display:none}.has-\[\:focus-within\]\:border-gray-300:has(:focus-within){--tw-border-opacity:1;border-color:rgb(153 153 153/var(--tw-border-opacity))}.has-\[\:focus\]\:border-gray-300:has(:focus){--tw-border-opacity:1;border-color:rgb(153 153 153/var(--tw-border-opacity))}@media (min-width:30rem){.sm\:gap-6{gap:1.5rem}}@media (min-width:40rem){.md\:col-span-3{grid-column:span 3/span 3}.md\:mb-0{margin-bottom:0}.md\:h-4{height:1rem}.md\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.md\:flex-row{flex-direction:row}.md\:items-center{align-items:center}.md\:gap-x-8{-moz-column-gap:2rem;column-gap:2rem}.md\:px-10{padding-left:2.5rem;padding-right:2.5rem}.md\:pb-0{padding-bottom:0}}@media (min-width:51.25rem){.lg\:order-2{order:2}.lg\:col-span-3{grid-column:span 3/span 3}.lg\:col-span-7{grid-column:span 7/span 7}.lg\:row-span-2{grid-row:span 2/span 2}.lg\:row-start-1{grid-row-start:1}.lg\:row-start-2{grid-row-start:2}.lg\:mb-4{margin-bottom:1rem}.lg\:mt-0{margin-top:0}.lg\:mt-10{margin-top:2.5rem}.lg\:block{display:block}.lg\:hidden{display:none}.lg\:grid-cols-10{grid-template-columns:repeat(10,minmax(0,1fr))}.lg\:flex-row{flex-direction:row}.lg\:justify-start{justify-content:flex-start}.lg\:justify-between{justify-content:space-between}.lg\:gap-x-20{-moz-column-gap:5rem;column-gap:5rem}.lg\:border-none{border-style:none}.lg\:bg-transparent{background-color:transparent}.lg\:p-0{padding:0}.lg\:px-0{padding-left:0;padding-right:0}.lg\:px-2{padding-left:.5rem;padding-right:.5rem}.lg\:py-1\.5{padding-top:.375rem;padding-bottom:.375rem}.lg\:pb-28{padding-bottom:7rem}.lg\:pt-2{padding-top:.5rem}.lg\:text-200{font-size:1rem}.lg\:font-semibold{font-weight:600}.lg\:hover\:bg-transparent:hover{background-color:transparent}}@media (min-width:63.75rem){.xl\:inline{display:inline}.xl\:hidden{display:none}.xl\:grid-cols-10{grid-template-columns:repeat(10,minmax(0,1fr))}.xl\:flex-row{flex-direction:row}.xl\:items-center{align-items:center}.xl\:gap-20{gap:5rem}.xl\:gap-6{gap:1.5rem}.xl\:pt-0{padding-top:0}}@media (min-width:85.375rem){.xxl\:inline-block{display:inline-block}.xxl\:h-4{height:1rem}.xxl\:w-4{width:1rem}.xxl\:gap-20{gap:5rem}.xxl\:gap-x-32{-moz-column-gap:8rem;column-gap:8rem}.xxl\:pr-1{padding-right:.25rem}} \ No newline at end of file diff --git a/gno.land/pkg/gnoweb/static.go b/gno.land/pkg/gnoweb/static.go new file mode 100644 index 00000000000..7900dcd7891 --- /dev/null +++ b/gno.land/pkg/gnoweb/static.go @@ -0,0 +1,28 @@ +package gnoweb + +import ( + "embed" + "net/http" +) + +//go:embed public/* +var assets embed.FS + +func disableCache(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "no-store") + next.ServeHTTP(w, r) + }) +} + +// AssetHandler returns the handler to serve static assets. If cache is true, +// these will be served using the static files embedded in the binary; otherwise +// they will served from the filesystem. +func AssetHandler() http.Handler { + return http.FileServer(http.FS(assets)) +} + +func DevAssetHandler(path, dir string) http.Handler { + handler := http.StripPrefix(path, http.FileServer(http.Dir(dir))) + return disableCache(handler) +} diff --git a/gno.land/pkg/gnoweb/static/css/app.css b/gno.land/pkg/gnoweb/static/css/app.css deleted file mode 100644 index c10fc8ec0e0..00000000000 --- a/gno.land/pkg/gnoweb/static/css/app.css +++ /dev/null @@ -1,862 +0,0 @@ -/**** ROBOTO ****/ - -@font-face { - font-family: "Roboto Mono"; - font-style: normal; - font-weight: normal; - font-display: swap; - src: local("Roboto Mono Regular"), url("/static/font/roboto/RobotoMono-Regular.woff") format("woff"); - } - - @font-face { - font-family: "Roboto Mono"; - font-style: italic; - font-weight: normal; - font-display: swap; - src: local("Roboto Mono Italic"), url("/static/font/roboto/RobotoMono-Italic.woff") format("woff"); - } - - @font-face { - font-family: "Roboto Mono Bold"; - font-style: normal; - font-weight: 700; - font-display: swap; - src: local("Roboto Mono Bold"), url("/static/font/roboto/RobotoMono-Bold.woff") format("woff"); - } - - @font-face { - font-family: "Roboto Mono"; - font-style: italic; - font-weight: 700; - font-display: swap; - src: local("Roboto Mono Bold Italic"), url("/static/font/roboto/RobotoMono-BoldItalic.woff") format("woff"); - } - - -/*** DARK/LIGHT THEME COLORS ***/ - -html:not([data-theme="dark"]), -html[data-theme="light"] { - --background-color: #eee; - --input-background-color: #eee; - --text-color: #000; - --link-color: #25172a; - --muted-color: #757575; - --border-color: #d7d9db; - --icon-color: #000; - - --quote-background: #ddd; - --quote-2-background: #aaa4; - --code-background: #d7d9db; - --header-background: #373737; - --header-forground: #ffffff; - --logo-hat: #ffffff; - --logo-beard: #808080; - - --realm-help-background-color: #d7d9db9e; - --realm-help-odd-background-color: #d7d9db45; - --realm-help-code-color: #5d5d5d; - - --highlight-color: #2f3337; - --highlight-bg: #f6f6f6; - --highlight-color: #2f3337; - --highlight-comment: #656e77; - --highlight-keyword: #015692; - --highlight-attribute: #015692; - --highlight-symbol: #803378; - --highlight-namespace: #b75501; - --highlight-keyword: #015692; - --highlight-variable: #54790d; - --highlight-keyword: #015692; - --highlight-literal: #b75501; - --highlight-punctuation: #535a60; - --highlight-variable: #54790d; - --highlight-deletion: #c02d2e; - --highlight-addition: #2f6f44; -} - -html[data-theme="dark"] { - --background-color: #1e1e1e; - --input-background-color: #393939; - --text-color: #c7c7c7; - --link-color: #c7c7c7; - --muted-color: #737373; - --border-color: #606060; - --icon-color: #dddddd; - - --quote-background: #404040; - --quote-2-background: #555555; - --code-background: #606060; - --header-background: #373737; - --header-forground: #ffffff; - --logo-hat: #ffffff; - --logo-beard: #808080; - - --realm-help-background-color: #45454545; - --realm-help-odd-background-color: #4545459e; - --realm-help-code-color: #b6b6b6; - - --highlight-color: #ffffff; - --highlight-bg: #1c1b1b; - --highlight-color: #ffffff; - --highlight-comment: #999999; - --highlight-keyword: #88aece; - --highlight-attribute: #88aece; - --highlight-symbol: #c59bc1; - --highlight-namespace: #f08d49; - --highlight-keyword: #88aece; - --highlight-variable: #b5bd68; - --highlight-keyword: #88aece; - --highlight-literal: #f08d49; - --highlight-punctuation: #cccccc; - --highlight-variable: #b5bd68; - --highlight-deletion: #de7176; - --highlight-addition: #76c490; -} - -.logo-wording path {fill: var(--header-forground, #ffffff); } -.logo-beard { fill: var(--logo-beard, #808080); } -.logo-hat {fill: var(--logo-hat, #ffffff); } - -#theme-toggle { - cursor: pointer; - display: inline-block; - padding: 0; - color: var(--header-forground, #ffffff); -} - -html[data-theme="dark"] #theme-toggle-moon, -html[data-theme="light"] #theme-toggle-sun { - display: none; -} - -/*** BASE HTML ELEMENTS ***/ - -* { - box-sizing: border-box; -} - -html { - font-feature-settings: "kern" on, "liga" on, "calt" on, "zero" on; - -webkit-font-feature-settings: "kern" on, "liga" on, "calt" on, "zero" on; - text-size-adjust: 100%; - -moz-osx-font-smoothing: grayscale; - font-smoothing: antialiased; - font-variant-ligatures: contextual common-ligatures; - font-kerning: normal; - text-rendering: optimizeLegibility; - -moz-text-size-adjust: none; - -webkit-text-size-adjust: none; - text-size-adjust: none; -} - -html, -body { - padding: 0; - margin: 0; - font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", - "Segoe UI Symbol", "Noto Color Emoji"; background-color: var(--background-color, #eee); - color: var(--text-color, #000); - font-size: 15px; - transition: 0.25s all ease; -} - -h1, -h2, -h3, -h4, -nav { - - font-weight: 600; - letter-spacing: 0.08rem; -} - -:is(h1, h2, h3, h4) a { - text-decoration: none; -} - -h1 { - text-align: center; - font-size: 2rem; - margin-block: 4.2rem 2rem; -} - -h2 { - font-size: 1.625rem; - margin-block: 3.4rem 1.2rem; - line-height: 1.4; -} - -h3 { - font-size: 1.467rem; - margin-block: 2.6rem 1rem; -} - -p { - font-size: 1rem; - margin-block: 1.2rem; - line-height: 1.4; -} - -p:last-child:has(a:only-child) { - margin-block-start: 0.8rem; -} -.stack > p:last-child:has(a:only-child) { - margin-block-start: 0; -} - -hr { - border: none; - height: 1px; - background: var(--border-color, #d7d9db); - width: 100%; - margin-block: 1.5rem 2rem; -} - -nav { - font-weight: 400; -} - -button { - color: var(--text-color, #000); -} - -body { - height: 100%; - width: 100%; -} - -input { - -webkit-appearance: none; - -moz-appearance: none; - appearance: none; -} - -a { - color: var(--link-color, #25172a); -} - -a[href="#"] { - color: var(--muted-color, #757575); -} - -.gno-tmpl-section ul { - padding: 0; -} - -.gno-tmpl-section li , -#header li , -.footer li { - list-style: none; -} - -.gno-tmpl-section blockquote { - margin-inline: 0; -} - -li { - margin-bottom: 0.4rem; -} - -li > * { - vertical-align: middle; -} - -input { - background-color: var(--input-background-color, #eee); - border: 1px solid var(--border-color); - color: var(--text-color, #000); - width: 25em; - padding: 0.4rem 0.5rem; - max-width: 100%;x -} - -blockquote { - background-color: var(--quote-background, #ddd); -} - -blockquote blockquote { - margin: 0; - background-color: var(--quote-2-background, #aaa4); -} - -pre, code { - font-family: "Roboto Mono", "Courier New", "sans-serif"; -} -pre { - background-color: var(--code-background, #d7d9db); - margin: 0; - padding: 0.5rem; -} - -label { - margin-block-end: 0.8rem; - display: block; -} - -label > img { - margin-inline-end: 0.8rem; -} - -code { - white-space: pre-wrap; - overflow-wrap: anywhere; -} -/*** COMPOSITION ***/ -.container { - width: 100%; - max-width: 63.75rem; - margin: auto; - padding: 1.25rem; -} - -.container p > img:only-child { - max-width: 100%; -} -.gno-tmpl-page p img:only-child { - margin-inline: auto; - display: block; - max-width: 100%; -} - -.inline-list { - padding: 1rem; - display: flex; - justify-content: space-between; -} - - - -.stack, -.stack > p { - display: flex; - flex-direction: column; -} - -.stack > p { - margin: 0; -} - -.stack > a, -.stack > p > a{ - margin-block-end: 0.4rem; -} - -.column > h1, -.column > h2, -.column > h3, -.column > h4, -.column > h5, -.column > h6 { - margin-block-start: 0; -} - -.columns-2, -.columns-3 { - display: grid; - grid-template-columns: repeat(1, 1fr); - grid-gap: 3.75rem; - margin: 3.75rem auto; -} - -.footer { - text-align: center; - margin-block-start: 2rem; - background-color: var(--header-background, #d7d9db); - border-top: 1px solid var(--border-color); -} - -.footer > .logo { - display: inline-block; - margin: 1rem; - height: 1.2rem; -} - -/** 51.2rem **/ -@media screen and (min-width: 68.75rem) { - .stack, - .stack > p { - flex-direction: row; - } - .stack *:not(:first-child) { - margin-left: 3.75rem; - } - .stack > a, - .stack > p > a{ - margin-block-end: 0; - } - .columns-2 { - grid-template-columns: repeat(2, 1fr); - } - .columns-3 { - grid-template-columns: repeat(3, 1fr); - } -} - -/*** UTILITIES ***/ - -.is-hidden { - display: none; -} - -.is-muted { - color: var(--muted-color, #757575); -} - -.is-finished { - text-decoration: line-through; -} - -.is-underline { - text-decoration: underline; -} - -/*** BLOCKS ***/ -.tabs button { - border: none; - cursor: pointer; - text-decoration: underline; - padding: 0; - background: none; - color: var(--text-color, #000); -} - -.tabs button[aria-selected="true"] { - font-weight: 700; -} - -.tabs + .jumbotron { - margin-top: 2.5rem; -} -.tabs > .columns-2, -.tabs > .columns-3 { - margin-bottom: 2.5rem; -} - -.accordion-trigger { - display: block; - border: none; - cursor: pointer; - padding: 0.4rem 0; - font-size: 1.125rem; - font-weight: 700; - text-align: left; - background: none; -} - -.accordion-trigger ~ div { - padding: 0.875rem 0 2.2rem; -} - -.accordion > p { - margin-block: 0; -} -/** 51.2rem **/ -@media screen and (min-width: 68.75rem) { - .accordion .accordion-trigger ~ div { - padding: 0.875rem 0 2.2rem 2rem; - } -} - -.gor-accordion button::first-letter { - font-size: 1.5em; - color: var(--text-color, #000); -} - -.jumbotron { - border: 1px solid var(--border-color, #d7d9db); - padding: 1.4rem; - margin: 3.75rem auto; -} - -.jumbotron h1 { - text-align: left; -} - -.jumbotron > *:first-child, -.jumbotron > * > *:first-child { - margin-block-start: 0; -} - -.jumbotron > *:last-child, -.jumbotron > * > *:last-child { - margin-block-end: 0; -} - -/** 68.75rem**/ -@media screen and (min-width: 68.75rem) { - .jumbotron { - margin: 3.75rem -3.5rem; - padding: 3.5rem; - } -} - -#root { - display: flex; - flex-direction: column; - border: 1px solid var(--header-background, #d7d9db); - margin: 20px; - overflow: hidden; - /* height: calc(100vh - 40px); */ -} - -#header { - position: relative; - background-color: var(--header-background, #d7d9db); - padding: 1.333rem; - display: flex; - align-items: center; - justify-content: space-between; -} - -#header > nav { - flex-grow: 2; -} - -#header .logo { - display: flex; - align-items: center; - color: var(--link-color, #25172a); - position: absolute; - height: 2.4rem; - z-index: 2; -} - -.logo > svg { - height: 100%; -} - -#logo_path a { - text-decoration: none; -} - -#logo_path { - padding-right: 0.8rem; -} - -#logo_path a:hover { - text-decoration: underline; -} - -#realm_links a { - font-size: 0.8rem; -} - -#header_buttons { - position: relative; - width: 100%; - height: 3rem; -} - -#header_buttons nav { - height: 100%; - display: flex; - justify-content: flex-end; - align-items: center; -} - -/* enabled conditionally with