├── .dockerignore ├── .env.example ├── .eslintignore ├── .eslintrc.js ├── .gitattributes ├── .github ├── actions │ └── run-polkadot │ │ └── action.yml └── workflows │ ├── ci.yml │ ├── github-issue-sync.yml │ ├── keep-polkadot-cache.yml │ ├── publish-deploy.yml │ ├── test-e2e-cron.yml │ ├── test-e2e.yml │ └── test-integration.yml ├── .gitignore ├── .papi ├── descriptors │ ├── .gitignore │ └── package.json ├── metadata │ ├── kusama.scale │ ├── polkadot.scale │ ├── rococo.scale │ └── westend.scale └── polkadot-api.json ├── .prettierignore ├── .prettierrc.js ├── .yarn └── releases │ └── yarn-4.3.0.cjs ├── .yarnrc.yml ├── CODEOWNERS ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── Dockerfile ├── Dockerfile.README.md ├── LICENSE ├── README.md ├── app.yml ├── helm ├── Chart.yaml ├── values-parity-prod.yaml ├── values-parity-stg.yaml └── values.yaml ├── jest.config.js ├── jest.e2e.config.js ├── jest.integration.config.js ├── jest.resolver.js ├── package.json ├── polkadot.e2e.patch ├── src ├── balance.ts ├── bot-handle-comment.ts ├── bot-initialize.ts ├── bot.ts ├── chain-config.ts ├── matrix.ts ├── metrics.ts ├── polkassembly │ ├── polkassembly-moonbase.integration.ts │ ├── polkassembly.integration.ts │ └── polkassembly.ts ├── testUtil.ts ├── tip-opengov.e2e.ts ├── tip-opengov.ts ├── tip.integration.ts ├── tip.ts ├── types.ts ├── util.test.ts └── util.ts ├── tsconfig.json └── yarn.lock /.dockerignore: -------------------------------------------------------------------------------- 1 | **/node_modules/ 2 | **/.git 3 | **/README.md 4 | **/LICENSE 5 | **/.vscode 6 | **/npm-debug.log 7 | **/coverage 8 | **/.env 9 | **/.editorconfig 10 | **/dist 11 | **/*.pem 12 | Dockerfile 13 | **/*.e2e.ts 14 | **/*.integration.ts 15 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # Used internally by probot: https://probot.github.io/docs/configuration/ 2 | WEBHOOK_PROXY_URL=https://smee.io/ 3 | WEBHOOK_SECRET= 4 | 5 | APP_ID= 6 | 7 | PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----\n private key \n-----END RSA PRIVATE KEY-----\n" 8 | 9 | ACCOUNT_SEED="twelve words seperated by spaces which seeds the account controlled by bot" 10 | 11 | APPROVERS_GH_ORG="paritytech" 12 | APPROVERS_GH_TEAM="tip-bot-approvers" 13 | 14 | POLKASSEMBLY_ENDPOINT="https://test.polkassembly.io/api/v1/" 15 | 16 | MATRIX_SERVER_URL="https://m.parity.io" 17 | MATRIX_ROOM_ID="!KiTmXyGkdiLNzrzMgj:parity.io" # ENG: Engineering Automation -> Bot Test Farm 18 | MATRIX_ACCESS_TOKEN="" 19 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | const { getConfiguration } = require("@eng-automation/js-style/src/eslint/configuration"); 2 | 3 | const conf = getConfiguration({ typescript: { rootDir: __dirname } }); 4 | 5 | conf.overrides[0].rules["@typescript-eslint/no-misused-promises"] = "off"; 6 | conf.overrides[0].rules["no-async-promise-executor"] = "off"; 7 | 8 | module.exports = conf; 9 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /.github/actions/run-polkadot/action.yml: -------------------------------------------------------------------------------- 1 | name: Run Polkadot action 2 | description: Reusable action to build and run a modified version of Polkadot 3 | inputs: 4 | polkadot-version: 5 | description: 'Version tag, e.g. polkadot-v1.7.1' 6 | required: true 7 | 8 | runs: 9 | using: "composite" 10 | steps: 11 | - uses: actions/checkout@v3.3.0 12 | with: 13 | path: substrate-tip-bot 14 | - uses: actions/checkout@v3.3.0 15 | with: 16 | repository: paritytech/polkadot-sdk 17 | ref: ${{ inputs.polkadot-version }} 18 | path: polkadot-sdk 19 | - name: Apply Polkadot patches 20 | run: git apply ../substrate-tip-bot/polkadot.e2e.patch 21 | shell: bash 22 | working-directory: polkadot-sdk 23 | - name: Restore cached Polkadot build 24 | id: polkadot-cache-restore 25 | uses: actions/cache/restore@v3 26 | with: 27 | path: | 28 | polkadot-sdk/target/release 29 | key: ${{ runner.os }}-${{ inputs.polkadot-version }}-${{ hashFiles('substrate-tip-bot/polkadot.e2e.patch') }} 30 | - name: Build Polkadot 31 | if: steps.polkadot-cache-restore.outputs.cache-hit != 'true' 32 | run: | 33 | cargo build --release --locked --features=fast-runtime -p polkadot 34 | shell: bash 35 | working-directory: polkadot-sdk 36 | - name: Save Polkadot build cache 37 | uses: actions/cache/save@v3 38 | with: 39 | path: | 40 | polkadot-sdk/target/release 41 | key: ${{ runner.os }}-${{ inputs.polkadot-version }}-${{ hashFiles('substrate-tip-bot/polkadot.e2e.patch') }} 42 | - name: Run a local Rococo node 43 | run: | 44 | polkadot-sdk/target/release/polkadot --rpc-external --no-prometheus --no-telemetry --chain=rococo-dev --tmp --alice --execution Native --unsafe-force-node-key-generation --rpc-port 9902 & 45 | until curl -s '127.0.0.1:9902'; do sleep 3; done 46 | shell: bash 47 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | pull_request: 5 | types: [opened, synchronize, reopened, ready_for_review] 6 | 7 | concurrency: 8 | group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} 9 | cancel-in-progress: true 10 | 11 | jobs: 12 | set-variables: 13 | name: Set variables 14 | runs-on: ubuntu-latest 15 | timeout-minutes: 10 16 | outputs: 17 | VERSION: ${{ steps.version.outputs.VERSION }} 18 | steps: 19 | - name: Define version 20 | id: version 21 | run: | 22 | export COMMIT_SHA=${{ github.sha }} 23 | export COMMIT_SHA_SHORT=${COMMIT_SHA:0:8} 24 | export REF_NAME=${{ github.ref_name }} 25 | export REF_SLUG=${REF_NAME//\//_} 26 | echo "short sha: ${COMMIT_SHA_SHORT} slug: ${REF_SLUG}" 27 | if [[ ${REF_SLUG} == "master" ]] 28 | then 29 | export VERSION=${REF_SLUG}-${COMMIT_SHA_SHORT} 30 | echo "VERSION=${VERSION}" >> $GITHUB_OUTPUT 31 | else 32 | export VERSION=${REF_SLUG} 33 | echo "VERSION=${VERSION}" >> $GITHUB_OUTPUT 34 | fi 35 | echo "set COMMIT_SHA_SHORT=${COMMIT_SHA_SHORT}" 36 | echo "set VERSION=${VERSION}" 37 | ci: 38 | name: Run lint, test 39 | runs-on: ubuntu-latest 40 | timeout-minutes: 10 41 | steps: 42 | - name: Check out the repo 43 | uses: actions/checkout@v4 44 | - uses: actions/setup-node@v4 45 | with: 46 | node-version: 22 47 | - name: Tests 48 | run: | 49 | yarn --immutable 50 | yarn lint 51 | yarn test 52 | 53 | build_image: 54 | name: Build docker image 55 | runs-on: ubuntu-latest 56 | needs: [set-variables] 57 | timeout-minutes: 10 58 | env: 59 | VERSION: ${{ needs.set-variables.outputs.VERSION }} 60 | IMAGE_NAME: "docker.io/paritytech/substrate-tip-bot" 61 | steps: 62 | - name: Check out the repo 63 | uses: actions/checkout@v4 64 | 65 | - name: Build Docker image 66 | uses: docker/build-push-action@v5 67 | with: 68 | context: . 69 | file: ./Dockerfile 70 | push: false 71 | tags: | 72 | ${{ env.IMAGE_NAME }}:${{ env.VERSION }} 73 | -------------------------------------------------------------------------------- /.github/workflows/github-issue-sync.yml: -------------------------------------------------------------------------------- 1 | name: GitHub Issue Sync 2 | 3 | on: 4 | issues: 5 | types: 6 | - opened 7 | workflow_dispatch: 8 | inputs: 9 | excludeClosed: 10 | description: 'Exclude closed issues in the sync.' 11 | type: boolean 12 | default: true 13 | 14 | jobs: 15 | sync: 16 | runs-on: ubuntu-latest 17 | steps: 18 | - name: Generate token 19 | id: generate_token 20 | uses: actions/create-github-app-token@v1 21 | with: 22 | app-id: ${{ secrets.PROJECT_APP_ID }} 23 | private-key: ${{ secrets.PROJECT_APP_KEY }} 24 | - name: Sync issues 25 | uses: paritytech/github-issue-sync@v0.3.2 26 | with: 27 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 28 | PROJECT_TOKEN: ${{ steps.generate_token.outputs.token }} 29 | project: 16 30 | project_field: Tool 31 | project_value: substrate-tip-bot 32 | -------------------------------------------------------------------------------- /.github/workflows/keep-polkadot-cache.yml: -------------------------------------------------------------------------------- 1 | name: Keep Polkadot cache 2 | on: 3 | schedule: 4 | - cron: 0 3 */6 * * # Every 6 days at 3 am. 5 | 6 | # The Github Actions cache is removed if not used for 7 days. 7 | # In this repository, we have a Polkadot build cache that takes a long time to build. 8 | # This workflow will restore the cache every 6 days, in order to preserve it. 9 | 10 | jobs: 11 | keep-polkadot-cache: 12 | timeout-minutes: 15 13 | runs-on: ubuntu-22.04 14 | env: 15 | POLKADOT_VERSION: 'polkadot-v1.7.1' 16 | 17 | steps: 18 | - uses: actions/checkout@v3.3.0 19 | with: 20 | path: substrate-tip-bot 21 | - name: Restore cached Polkadot build 22 | uses: actions/cache/restore@v3 23 | with: 24 | path: | 25 | polkadot-sdk/target/release 26 | key: ${{ runner.os }}-${{ env.POLKADOT_VERSION }}-${{ hashFiles('substrate-tip-bot/polkadot.e2e.patch') }} 27 | -------------------------------------------------------------------------------- /.github/workflows/publish-deploy.yml: -------------------------------------------------------------------------------- 1 | name: Publish and deploy 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | tags: 8 | - v* 9 | - stg-v* 10 | 11 | concurrency: 12 | group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} 13 | cancel-in-progress: true 14 | 15 | #to use reusable workflow 16 | permissions: 17 | id-token: write 18 | contents: read 19 | 20 | env: 21 | APP: "substrate-tip-bot" 22 | 23 | jobs: 24 | set-variables: 25 | name: Set variables 26 | runs-on: ubuntu-latest 27 | outputs: 28 | VERSION: ${{ steps.version.outputs.VERSION }} 29 | steps: 30 | - name: Define version 31 | id: version 32 | run: | 33 | export COMMIT_SHA=${{ github.sha }} 34 | export COMMIT_SHA_SHORT=${COMMIT_SHA:0:8} 35 | export REF_NAME=${{ github.ref_name }} 36 | export REF_SLUG=${REF_NAME//\//_} 37 | echo "short sha: ${COMMIT_SHA_SHORT} slug: ${REF_SLUG}" 38 | if [[ ${REF_SLUG} == "master" ]] 39 | then 40 | export VERSION=${REF_SLUG}-${COMMIT_SHA_SHORT} 41 | echo "VERSION=${VERSION}" >> $GITHUB_OUTPUT 42 | else 43 | export VERSION=${REF_SLUG} 44 | echo "VERSION=${VERSION}" >> $GITHUB_OUTPUT 45 | fi 46 | echo "set VERSION=${VERSION}" 47 | build_push_docker: 48 | name: Build docker image 49 | runs-on: ubuntu-latest 50 | environment: master_n_tags 51 | needs: [set-variables] 52 | env: 53 | VERSION: ${{ needs.set-variables.outputs.VERSION }} 54 | steps: 55 | - name: Check out the repo 56 | uses: actions/checkout@v4 57 | - name: Log in to Docker Hub 58 | uses: docker/login-action@v3 59 | with: 60 | username: ${{ secrets.DOCKERHUB_USERNAME }} 61 | password: ${{ secrets.DOCKERHUB_PASSWORD }} 62 | 63 | - name: Build Docker image 64 | uses: docker/build-push-action@v5 65 | with: 66 | context: . 67 | file: ./Dockerfile 68 | push: true 69 | tags: | 70 | docker.io/paritytech/substrate-tip-bot:${{ env.VERSION }} 71 | 72 | deploy-stg: 73 | name: Deploy Staging 74 | runs-on: ubuntu-latest 75 | needs: [set-variables, build_push_docker] 76 | environment: parity-stg 77 | env: 78 | VERSION: ${{ needs.set-variables.outputs.VERSION }} 79 | ARGOCD_SERVER: "argocd-stg.teleport.parity.io" 80 | steps: 81 | - name: Deploy to ArgoCD 82 | uses: paritytech/argocd-deployment-action@main 83 | with: 84 | environment: "parity-stg" 85 | tag: "${{ env.VERSION }}" 86 | app_name: "${{ env.APP }}" 87 | app_packages: "common" 88 | argocd_server: ${{ env.ARGOCD_SERVER }} 89 | teleport_token: "substrate-tip" 90 | teleport_app_name: "argocd-stg" 91 | argocd_auth_token: ${{ secrets.ARGOCD_AUTH_TOKEN }} 92 | 93 | deploy-prod: 94 | name: Deploy Production 95 | runs-on: ubuntu-latest 96 | needs: [set-variables, deploy-stg] 97 | # deploy only on tags 98 | if: startsWith(github.ref, 'refs/tags/v') 99 | environment: parity-prod 100 | env: 101 | VERSION: ${{ needs.set-variables.outputs.VERSION }} 102 | ARGOCD_SERVER: "argocd-prod.teleport.parity.io" 103 | steps: 104 | - name: Deploy to ArgoCD 105 | uses: paritytech/argocd-deployment-action@main 106 | with: 107 | environment: "parity-prod" 108 | tag: "${{ env.VERSION }}" 109 | app_name: "${{ env.APP }}" 110 | app_packages: "common" 111 | argocd_server: ${{ env.ARGOCD_SERVER }} 112 | teleport_token: "substrate-tip" 113 | teleport_app_name: "argocd-prod" 114 | argocd_auth_token: ${{ secrets.ARGOCD_AUTH_TOKEN }} 115 | -------------------------------------------------------------------------------- /.github/workflows/test-e2e-cron.yml: -------------------------------------------------------------------------------- 1 | name: E2E tests against most recent Polkadot release 2 | on: 3 | schedule: 4 | - cron: 0 3 * * SUN # Once a week on Sunday. 5 | workflow_dispatch: 6 | 7 | jobs: 8 | test-e2e-cron: 9 | runs-on: ubuntu-22.04 10 | timeout-minutes: 120 11 | container: "${{ vars.E2E_TESTS_CONTAINER }}" 12 | steps: 13 | - name: Read latest polkadot tag 14 | id: read-tag 15 | run: | 16 | # Fetch all Polkadot release tags, get the last (newest) one, and parse its name from the output. 17 | TAG=$(git ls-remote --refs --tags https://github.com/paritytech/polkadot-sdk.git 'polkadot-stable*' \ 18 | | sed 's,[^r]*refs/tags/,,' \ 19 | | grep -v '\-rc*' \ 20 | | sort --version-sort \ 21 | | tail -1) 22 | echo "tag=$TAG" >> $GITHUB_OUTPUT 23 | - name: Announce version 24 | run: echo "Running tests with Polkadot version ${{ steps.read-tag.outputs.tag }}" 25 | - uses: actions/checkout@v4 26 | - name: Start a local Polkadot node 27 | uses: ./.github/actions/run-polkadot 28 | with: 29 | polkadot-version: "${{ steps.read-tag.outputs.tag }}" 30 | - name: Wait for the node 31 | run: | 32 | until curl -s '127.0.0.1:9902'; do sleep 3; done 33 | timeout-minutes: 1 34 | - uses: actions/setup-node@v4 35 | with: 36 | node-version: "22.5.1" 37 | - run: yarn --immutable 38 | - run: yarn test:e2e 39 | timeout-minutes: 10 40 | -------------------------------------------------------------------------------- /.github/workflows/test-e2e.yml: -------------------------------------------------------------------------------- 1 | name: E2E tests 2 | on: 3 | pull_request: 4 | push: 5 | branches: 6 | - master 7 | 8 | concurrency: 9 | group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} 10 | cancel-in-progress: true 11 | 12 | jobs: 13 | test-e2e: 14 | runs-on: ubuntu-22.04 15 | timeout-minutes: 120 16 | container: "${{ vars.E2E_TESTS_CONTAINER }}" 17 | steps: 18 | - uses: actions/checkout@v3.3.0 19 | - name: Start a local Polkadot node 20 | uses: ./.github/actions/run-polkadot 21 | with: 22 | polkadot-version: polkadot-v1.15.0 23 | - name: Wait for the node 24 | run: | 25 | until curl -s '127.0.0.1:9902'; do sleep 3; done 26 | timeout-minutes: 1 27 | - uses: actions/setup-node@v3.5.1 28 | with: 29 | node-version: "22.5.1" 30 | - run: yarn --immutable 31 | - run: yarn test:e2e 32 | timeout-minutes: 10 33 | -------------------------------------------------------------------------------- /.github/workflows/test-integration.yml: -------------------------------------------------------------------------------- 1 | name: Integration tests 2 | on: 3 | pull_request: 4 | push: 5 | branches: 6 | - master 7 | 8 | concurrency: 9 | group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} 10 | cancel-in-progress: true 11 | 12 | jobs: 13 | test-integration: 14 | timeout-minutes: 15 15 | runs-on: ubuntu-22.04 16 | steps: 17 | - uses: actions/checkout@v4 18 | - uses: actions/setup-node@v4 19 | with: 20 | node-version: 22.5.1 21 | - run: yarn --immutable 22 | - run: yarn build:docker 23 | - run: yarn test:integration --verbose 24 | - name: Debug application container logs 25 | if: failure() 26 | run: cat integration_tests/containter_logs/application.log 27 | - name: Debug rococo container logs 28 | if: failure() 29 | run: cat integration_tests/containter_logs/rococo.log 30 | - name: Debug westend container logs 31 | if: failure() 32 | run: cat integration_tests/containter_logs/westend.log 33 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | coverage/ 3 | lib/ 4 | dist/ 5 | /integration_tests 6 | 7 | #IDE 8 | .idea 9 | 10 | npm-debug.log 11 | *.pem 12 | !mock-cert.pem 13 | .env 14 | .eslintcache 15 | yarn-error.log 16 | .yarn/install-state.gz 17 | -------------------------------------------------------------------------------- /.papi/descriptors/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | !package.json -------------------------------------------------------------------------------- /.papi/descriptors/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.1.0-autogenerated.13825733048180440900", 3 | "name": "@polkadot-api/descriptors", 4 | "files": [ 5 | "dist" 6 | ], 7 | "exports": { 8 | ".": { 9 | "types": "./dist/index.d.ts", 10 | "module": "./dist/index.mjs", 11 | "import": "./dist/index.mjs", 12 | "require": "./dist/index.js" 13 | }, 14 | "./package.json": "./package.json" 15 | }, 16 | "main": "./dist/index.js", 17 | "module": "./dist/index.mjs", 18 | "browser": "./dist/index.mjs", 19 | "types": "./dist/index.d.ts", 20 | "sideEffects": false, 21 | "peerDependencies": { 22 | "polkadot-api": "*" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /.papi/metadata/kusama.scale: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paritytech/substrate-tip-bot/8f6d5258c708cfd1063f972c1e04b6c1a89082f1/.papi/metadata/kusama.scale -------------------------------------------------------------------------------- /.papi/metadata/polkadot.scale: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paritytech/substrate-tip-bot/8f6d5258c708cfd1063f972c1e04b6c1a89082f1/.papi/metadata/polkadot.scale -------------------------------------------------------------------------------- /.papi/metadata/rococo.scale: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paritytech/substrate-tip-bot/8f6d5258c708cfd1063f972c1e04b6c1a89082f1/.papi/metadata/rococo.scale -------------------------------------------------------------------------------- /.papi/metadata/westend.scale: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paritytech/substrate-tip-bot/8f6d5258c708cfd1063f972c1e04b6c1a89082f1/.papi/metadata/westend.scale -------------------------------------------------------------------------------- /.papi/polkadot-api.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": 0, 3 | "descriptorPath": ".papi/descriptors", 4 | "entries": { 5 | "polkadot": { 6 | "wsUrl": "wss://rpc.polkadot.io", 7 | "chain": "polkadot", 8 | "metadata": ".papi/metadata/polkadot.scale" 9 | }, 10 | "kusama": { 11 | "wsUrl": "wss://kusama-rpc.polkadot.io", 12 | "chain": "ksmcc3", 13 | "metadata": ".papi/metadata/kusama.scale" 14 | }, 15 | "westend": { 16 | "wsUrl": "wss://westend-rpc.polkadot.io", 17 | "chain": "westend2", 18 | "metadata": ".papi/metadata/westend.scale" 19 | }, 20 | "rococo": { 21 | "wsUrl": "wss://rococo-rpc.polkadot.io", 22 | "chain": "rococo_v2_2", 23 | "metadata": ".papi/metadata/rococo.scale" 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = require("@eng-automation/js-style/src/prettier/configuration") 2 | -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | yarnPath: .yarn/releases/yarn-4.3.0.cjs 2 | nodeLinker: node-modules 3 | -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @paritytech/opstooling 2 | 3 | # CI 4 | /.gitlab-ci.yml @paritytech/ci @paritytech/opstooling 5 | /.github @paritytech/ci @paritytech/opstooling 6 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, gender identity and expression, level of experience, 9 | education, socio-economic status, nationality, personal appearance, race, 10 | religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | - Using welcoming and inclusive language 18 | - Being respectful of differing viewpoints and experiences 19 | - Gracefully accepting constructive criticism 20 | - Focusing on what is best for the community 21 | - Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | - The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | - Trolling, insulting/derogatory comments, and personal or political attacks 28 | - Public or private harassment 29 | - Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | - Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at contact@parity.io. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## Contributing 2 | 3 | [fork]: /fork 4 | [pr]: /compare 5 | [code-of-conduct]: CODE_OF_CONDUCT.md 6 | 7 | Hi there! We're thrilled that you'd like to contribute to this project. Your help is essential for keeping it great. 8 | 9 | Please note that this project is released with a [Contributor Code of Conduct][code-of-conduct]. By participating in this project you agree to abide by its terms. 10 | 11 | ## Issues and PRs 12 | 13 | If you have suggestions for how this project could be improved, or want to report a bug, open an issue! We'd love all and any contributions. If you have questions, too, we'd love to hear them. 14 | 15 | We'd also love PRs. If you're thinking of a large PR, we advise opening up an issue first to talk about it, though! Look at the links below if you're not sure how to open a PR. 16 | 17 | ## Submitting a pull request 18 | 19 | 1. [Fork][fork] and clone the repository. 20 | 1. Configure and install the dependencies: `yarn install`. 21 | 1. Make sure the tests pass on your machine: `yarn test`, note: these tests also apply the linter, so there's no need to lint separately. 22 | 1. Create a new branch: `git checkout -b my-branch-name`. 23 | 1. Make your change, add tests, and make sure the tests still pass. 24 | 1. Push to your fork and [submit a pull request][pr]. 25 | 1. Pat your self on the back and wait for your pull request to be reviewed and merged. 26 | 27 | Here are a few things you can do that will increase the likelihood of your pull request being accepted: 28 | 29 | - Write and update tests. 30 | - Keep your changes as focused as possible. If there are multiple changes you would like to make that are not dependent upon each other, consider submitting them as separate pull requests. 31 | - Write a [good commit message](http://tbaggery.com/2008/04/19/a-note-about-git-commit-messages.html). 32 | 33 | Work in Progress pull requests are also welcome to get feedback early on, or if there is something blocked you. 34 | 35 | ## Resources 36 | 37 | - [How to Contribute to Open Source](https://opensource.guide/how-to-contribute/) 38 | - [Using Pull Requests](https://help.github.com/articles/about-pull-requests/) 39 | - [GitHub Help](https://help.github.com) 40 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:22.5.1-alpine AS builder 2 | 3 | WORKDIR /usr/src/app 4 | 5 | COPY package.json yarn.lock .yarnrc.yml tsconfig.json ./ 6 | COPY .yarn/ ./.yarn/ 7 | COPY .papi/ ./.papi/ 8 | RUN yarn install --immutable 9 | COPY src/ ./src 10 | 11 | RUN yarn build 12 | 13 | FROM node:22.5.1-slim 14 | 15 | # metadata 16 | ARG VCS_REF=master 17 | ARG BUILD_DATE="" 18 | ARG REGISTRY_PATH=docker.io/paritytech 19 | ARG PROJECT_NAME="" 20 | 21 | LABEL io.parity.image.authors="cicd-team@parity.io" \ 22 | io.parity.image.vendor="Parity Technologies" \ 23 | io.parity.image.title="${REGISTRY_PATH}/${PROJECT_NAME}" \ 24 | io.parity.image.description="Substrate Tip bot" \ 25 | io.parity.image.source="https://github.com/paritytech/${PROJECT_NAME}/blob/${VCS_REF}/Dockerfile" \ 26 | io.parity.image.documentation="https://github.com/paritytech/${PROJECT_NAME}/blob/${VCS_REF}/README.md" \ 27 | io.parity.image.revision="${VCS_REF}" \ 28 | io.parity.image.created="${BUILD_DATE}" 29 | 30 | WORKDIR /usr/src/app 31 | 32 | COPY --from=builder /usr/src/app/package.json ./ 33 | COPY --from=builder /usr/src/app/dist ./dist/ 34 | COPY --from=builder /usr/src/app/node_modules ./node_modules/ 35 | COPY --from=builder /usr/src/app/.papi ./.papi/ 36 | 37 | ENV NODE_ENV="production" 38 | 39 | CMD [ "node", "--enable-source-maps", "dist/src/bot.js" ] 40 | -------------------------------------------------------------------------------- /Dockerfile.README.md: -------------------------------------------------------------------------------- 1 | # substrate-tip-bot 2 | 3 | ## A GitHub App built with Probot that can submit tips on behalf of a Substrate based network. 4 | 5 | To run the bot via Docker: 6 | 7 | ``` 8 | $ docker run \ 9 | -e APP_ID= \ 10 | -e PRIVATE_KEY= \ 11 | paritytech/substrate-tip-bot 12 | ``` 13 | 14 | 15 | ### More on a [GitHub](https://github.com/paritytech/substrate-tip-bot) 16 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Parity Technologies 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # substrate-tip-bot 2 | 3 | [![GitHub Issue Sync](https://github.com/paritytech/substrate-tip-bot/actions/workflows/github-issue-sync.yml/badge.svg)](https://github.com/paritytech/substrate-tip-bot/actions/workflows/github-issue-sync.yml) 4 | 5 | > A GitHub App built with [Probot](https://github.com/probot/probot) that can submit tips on behalf 6 | > of a [Substrate](https://github.com/paritytech/substrate) based network. 7 | 8 | # Getting started 🌱 9 | 10 | ## Usage 11 | 12 | This bot relies on GitHub pull request that opt in via a body text comment (or text in profile bio) to specify what Substrate network and address to send tips to. 13 | 14 | Permission to send out tips is limited for a GitHub team, that's configured with `APPROVERS_GH_ORG` + `APPROVERS_GH_TEAM` environment variables. For production, it's [@paritytech/tip-bot-approvers](https://github.com/orgs/paritytech/teams/tip-bot-approvers) 15 | 16 | ### Pull request body 17 | 18 | ```sh 19 | {kusama|polkadot|rococo|westend} address: 20 | ``` 21 | 22 | Followed by a _comment_ on said pull request 23 | 24 | ### Pull request comment 25 | 26 | ```sh 27 | /tip {small | medium | large | } 28 | ``` 29 | 30 | In OpenGov, the tip sizes are translated to specific values as follows: 31 | 32 | Size | Value on Kusama | Value on Polkadot 33 | --- | --- | --- 34 | small | 4 KSM | 20 DOT 35 | medium | 16 KSM | 80 DOT 36 | large | 30 KSM | 150 DOT 37 | 38 | ## Local development 🔧 39 | 40 | To use this bot, you'll need to have an `.env` file. Most of the options will 41 | automatically be generated by the GitHub application creation process, but you will also need to add 42 | `ACCOUNT_SEED`, `APPROVERS_GH_ORG` and `APPROVERS_GH_TEAM`. 43 | 44 | A reference env file is placed at `.env.example` to copy over 45 | 46 | ```sh 47 | $ cp .env.example .env 48 | ``` 49 | 50 | ### Run network locally 51 | 52 | - Follow readme in https://github.com/paritytech/polkadot#development to run local network. 53 | - Among all dependencies, main steps are (from repo): 54 | - Compile `cargo b -r` 55 | - Run `./target/release/polkadot --dev` 56 | - Alternatively, run a docker container: `docker run -p 9933:9933 -p 9944:9944 parity/polkadot --dev` 57 | - [Create 2 accounts: for "bot" & for "contributor"](https://polkadot.js.org/apps/?rpc=ws%3A%2F%2F127.0.0.1%3A9944#/accounts) 58 | - Save the seeds & passwords somewhere 59 | - Set `ACCOUNT_SEED` as bot's seed in `.env` file 60 | - Create a team in GitHub for the org that you control, and set `APPROVERS_GH_ORG` and `APPROVERS_GH_TEAM` in `.env` to said org and team 61 | - Set `LOCALNETWORKS=true` in `.env`, to change the endpoints 62 | - Transfer some meaningful amount from test accounts (like Alice) to a new bot account (from which bot will send tip to the contributor) 63 | 64 | ### Create GitHub application for testing 65 | 66 | - Note: During app creation save according env variables to `.env` file 67 | - Read [Getting-started](https://gitlab.parity.io/groups/parity/opstooling/-/wikis/Bots/Development/Getting-started) doc to get a sense of how to work with bots 68 | - Follow [creating app](https://gitlab.parity.io/groups/parity/opstooling/-/wikis/Bots/Development/Create-a-new-GitHub-App) 69 | and [installing app](https://gitlab.parity.io/groups/parity/opstooling/-/wikis/Bots/Development/Installing-the-GitHub-App) 70 | guidance 71 | - `WEBHOOK_PROXY_URL` you can generate via https://smee.io/new 72 | 73 | ### Integration tests 74 | 75 | There are integration tests that execute the tip functions against a locally running Polkadot and Kusama nodes. 76 | 77 | The tests will spin up the local nodes automatically. 78 | 79 | To run the tests: 80 | 81 | Build the application image: 82 | ```bash 83 | yarn build:docker 84 | ``` 85 | 86 | Then run the tests: 87 | 88 | ```bash 89 | yarn test:integration 90 | ``` 91 | 92 | #### Github app permissions 93 | 94 | ##### Repository permissions: 95 | - **Issues**: Read-only 96 | - Allows for interacting with the comments API 97 | - **Pull Requests**: Read & write 98 | - Allows for posting comments on pull requests 99 | ##### Organization permissions 100 | - **Members**: Read-only 101 | - Related to $ALLOWED_ORGANIZATIONS: this permission enables the bot to request the organization membership of the command's requester even if their membership is private 102 | ##### Event subscriptions 103 | - **Issue comment** 104 | - Allows for receiving events for pull request comments 105 | 106 | ### Start a bot 107 | 108 | After registering and configuring the bot environment, we can run it. 109 | 110 | ```sh 111 | $ yarn start 112 | ``` 113 | 114 | ### Create a PR and test it 115 | You'll need 2 gh users: contributor and maintainer (since it's not allowed for contributors to send a tip to themselves) 116 | 117 | - From contributor GH account: create a PR and add into PR description `rococo address: ` 118 | - From maintainer GH account: write `/tip small` in comments so the bot sends funds to 119 | 120 | ### Docker 121 | 122 | To run the bot via Docker, we need to build and then run it like so 123 | 124 | ```sh 125 | $ docker build -t substrate-tip-bot . 126 | ``` 127 | 128 | ```sh 129 | $ docker run \ 130 | -e APP_ID= \ 131 | -e PRIVATE_KEY= \ 132 | substrate-tip-bot 133 | ``` 134 | 135 | ## End-to-end tests 136 | 137 | For the E2E tests, we need a modified Rococo node in a way that speeds up the referenda and treasury. 138 | 139 | ### Preparing and running Rococo 140 | 141 | ```bash 142 | git clone https://github.com/paritytech/polkadot-sdk.git 143 | cd polkadot-sdk 144 | git checkout polkadot-v1.15.0 145 | git apply ../polkadot.e2e.patch 146 | cargo build --release --locked --features=fast-runtime -p polkadot 147 | ./target/release/polkadot --rpc-external --no-prometheus --no-telemetry --chain=rococo-dev --tmp --alice --execution Native --unsafe-force-node-key-generation --rpc-port 9902 148 | ``` 149 | 150 | You might need to fund the treasury (`13UVJyLnbVp9RBZYFwFGyDvVd1y27Tt8tkntv6Q7JVPhFsTB`) if it is broke. 151 | 152 | #### Patch 153 | 154 | The `polkadot-sdk` code is patched (see the line doing `git apply`) - that's why we have to build the code from source, instead of using a released binary or docker image. 155 | 156 | The patch involves changing the timelines around OpenGov mechanics, so that we can test the whole flow in a reasonable amount of time. 157 | 158 | ### Running the E2E tests 159 | 160 | ```bash 161 | yarn test:e2e 162 | ``` 163 | 164 | Go make a cup of tea, the tests take ~3 minutes (waiting for the various on-chain stages to pass). 165 | 166 | On CI, the E2E tests are running: 167 | 168 | - On every PR and commits pushed to `master`, against a fixed release of `polkadot-sdk`. 169 | - Periodically, against a most recent release of `polkadot-sdk`. 170 | 171 | The tests are running in a container specified by `E2E_TESTS_CONTAINER` [repository variable](https://docs.github.com/en/actions/learn-github-actions/variables#defining-configuration-variables-for-multiple-workflows). 172 | 173 | The container version should be kept in sync with the container used in [`polkadot-sdk` CI](https://github.com/paritytech/polkadot-sdk/blob/polkadot-v1.7.2/.github/workflows/fmt-check.yml#L18). 174 | 175 | ## Contributing 176 | 177 | If you have suggestions for how substrate-tip-bot could be improved, or want to report a bug, open 178 | an issue! We'd love all and any contributions. 179 | 180 | For more, check out the [Contributing Guide](CONTRIBUTING.md). 181 | 182 | ## License 183 | 184 | [MIT](LICENSE) © 2021 Parity Technologies 185 | -------------------------------------------------------------------------------- /app.yml: -------------------------------------------------------------------------------- 1 | # This is a GitHub App Manifest. These settings will be used by default when 2 | # initially configuring your GitHub App. 3 | # 4 | # NOTE: changing this file will not update your GitHub App settings. 5 | # You must visit github.com/settings/apps/your-app-name to edit them. 6 | # 7 | # Read more about configuring your GitHub App: 8 | # https://probot.github.io/docs/development/#configuring-a-github-app 9 | # 10 | # Read more about GitHub App Manifests: 11 | # https://developer.github.com/apps/building-github-apps/creating-github-apps-from-a-manifest/ 12 | 13 | # The list of events the GitHub App subscribes to. 14 | # Uncomment the event names below to enable them. 15 | default_events: 16 | - issue_comment 17 | - issues 18 | - pull_request 19 | - pull_request_review 20 | - pull_request_review_comment 21 | 22 | default_permissions: 23 | issues: write 24 | metadata: read 25 | pull_requests: write 26 | -------------------------------------------------------------------------------- /helm/Chart.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v2 2 | name: substrate-tip-bot 3 | description: A Helm chart for Kubernetes 4 | type: application 5 | version: 0.1.0 6 | appVersion: "1.0.0" 7 | dependencies: 8 | - name: common 9 | version: "0.7.2" 10 | repository: "https://paritytech.github.io/helm-charts/" -------------------------------------------------------------------------------- /helm/values-parity-prod.yaml: -------------------------------------------------------------------------------- 1 | common: 2 | env: 3 | APP_HOST: "https://substrate-tip-bot.parity-prod.parity.io" 4 | APPROVERS_GH_ORG: "paritytech" 5 | APPROVERS_GH_TEAM: "tip-bot-approvers" 6 | POLKASSEMBLY_ENDPOINT: "https://api.polkassembly.io/api/v1/" 7 | MATRIX_SERVER_URL: "https://m.parity.io" 8 | MATRIX_ROOM_ID: "!tQFMxBymnjGQYNwlzM:parity.io" # ENG: Engineering Automation -> tip-bot 9 | secrets: 10 | # WEBHOOK_SECRET is used internally by probot: https://probot.github.io/docs/configuration/ 11 | WEBHOOK_SECRET: ref+vault://kv/argo-cd/substrate-tip-bot/opstooling-parity-prod#WEBHOOK_SECRET 12 | PRIVATE_KEY: ref+vault://kv/argo-cd/substrate-tip-bot/opstooling-parity-prod#PRIVATE_KEY 13 | ACCOUNT_SEED: ref+vault://kv/argo-cd/substrate-tip-bot/opstooling-parity-prod#ACCOUNT_SEED 14 | APP_ID: ref+vault://kv/argo-cd/substrate-tip-bot/opstooling-parity-prod#APP_ID 15 | MATRIX_ACCESS_TOKEN: ref+vault://kv/argo-cd/substrate-tip-bot/opstooling-parity-prod#MATRIX_ACCESS_TOKEN 16 | ingress: 17 | annotations: 18 | external-dns.alpha.kubernetes.io/target: traefik-external.parity-prod.parity.io. 19 | rules: 20 | - host: substrate-tip-bot.parity-prod.parity.io 21 | http: 22 | paths: 23 | - path: / 24 | pathType: ImplementationSpecific 25 | backend: 26 | service: 27 | name: substrate-tip-bot 28 | port: 29 | name: http 30 | tls: 31 | - hosts: 32 | - substrate-tip-bot.parity-prod.parity.io 33 | secretName: substrate-tip-bot.parity-prod.parity.io -------------------------------------------------------------------------------- /helm/values-parity-stg.yaml: -------------------------------------------------------------------------------- 1 | common: 2 | env: 3 | APP_HOST: "https://substrate-tip-bot.parity-stg.parity.io" 4 | APPROVERS_GH_ORG: "paritytech-stg" 5 | APPROVERS_GH_TEAM: "tip-bot-approvers" 6 | POLKASSEMBLY_ENDPOINT: "https://test.polkassembly.io/api/v1/" 7 | MATRIX_SERVER_URL: "https://m.parity.io" 8 | MATRIX_ROOM_ID: "!KiTmXyGkdiLNzrzMgj:parity.io" # ENG: Engineering Automation -> Bot Test Farm 9 | secrets: 10 | # WEBHOOK_SECRET is used internally by probot: https://probot.github.io/docs/configuration/ 11 | WEBHOOK_SECRET: ref+vault://kv/argo-cd/substrate-tip-bot/opstooling-parity-stg#WEBHOOK_SECRET 12 | PRIVATE_KEY: ref+vault://kv/argo-cd/substrate-tip-bot/opstooling-parity-stg#PRIVATE_KEY 13 | ACCOUNT_SEED: ref+vault://kv/argo-cd/substrate-tip-bot/opstooling-parity-stg#ACCOUNT_SEED 14 | APP_ID: ref+vault://kv/argo-cd/substrate-tip-bot/opstooling-parity-stg#APP_ID 15 | MATRIX_ACCESS_TOKEN: ref+vault://kv/argo-cd/substrate-tip-bot/opstooling-parity-stg#MATRIX_ACCESS_TOKEN 16 | ingress: 17 | annotations: 18 | external-dns.alpha.kubernetes.io/target: traefik-external.parity-stg.parity.io. 19 | rules: 20 | - host: substrate-tip-bot.parity-stg.parity.io 21 | http: 22 | paths: 23 | - path: / 24 | pathType: ImplementationSpecific 25 | backend: 26 | service: 27 | name: substrate-tip-bot 28 | port: 29 | name: http 30 | tls: 31 | - hosts: 32 | - substrate-tip-bot.parity-stg.parity.io 33 | secretName: substrate-tip-bot.parity-stg.parity.io -------------------------------------------------------------------------------- /helm/values.yaml: -------------------------------------------------------------------------------- 1 | common: 2 | fullnameOverride: "substrate-tip-bot" 3 | extraLabels: 4 | team: "opstooling" 5 | serviceAccount: 6 | create: false 7 | image: 8 | # tag is set in ci https://github.com/paritytech/substrate-tip-bot/blob/72a5a0228c0405e211f6ff768cfd4010b3323658/.gitlab-ci.yml#L152 9 | repository: paritytech/substrate-tip-bot 10 | envFrom: 11 | - secretRef: 12 | name: substrate-tip-bot 13 | service: 14 | ports: 15 | - name: http 16 | protocol: TCP 17 | port: 80 18 | targetPort: 3000 19 | ingress: 20 | enabled: true 21 | annotations: 22 | cert-manager.io/cluster-issuer: letsencrypt-dns01 23 | kubernetes.io/ingress.class: traefik-external 24 | traefik.ingress.kubernetes.io/router.entrypoints: web,websecure 25 | traefik.ingress.kubernetes.io/router.tls: "true" 26 | livenessProbe: 27 | httpGet: 28 | path: /tip-bot/health 29 | port: http 30 | initialDelaySeconds: 60 31 | periodSeconds: 5 32 | readinessProbe: 33 | httpGet: 34 | path: /tip-bot/health 35 | port: http 36 | initialDelaySeconds: 60 37 | periodSeconds: 5 38 | serviceMonitor: 39 | enabled: true 40 | endpoints: 41 | - port: http 42 | path: /tip-bot/metrics 43 | interval: 1m 44 | scheme: http 45 | scrapeTimeout: 30s 46 | honorLabels: true 47 | targetLabels: 48 | - team -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import("ts-jest/dist/types").InitialOptionsTsJest} */ 2 | module.exports = { 3 | preset: "ts-jest", 4 | testEnvironment: "node", 5 | roots: ["./src"], 6 | testTimeout: 30_000, 7 | // Couldn't fix an issue with moduleNameMapper and subpath imports both locally 8 | // and in node_modules. Ended up with a custom resolver. 9 | // https://github.com/jestjs/jest/issues/14032 10 | // https://github.com/jestjs/jest/issues/12270 11 | resolver: "/jest.resolver.js", 12 | }; 13 | -------------------------------------------------------------------------------- /jest.e2e.config.js: -------------------------------------------------------------------------------- 1 | const commonConfig = require("./jest.config.js"); 2 | 3 | process.env.LOCAL_NETWORKS = "true"; 4 | 5 | /** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */ 6 | module.exports = { ...commonConfig, testMatch: ["**/?(*.)+(e2e).[jt]s?(x)"], testTimeout: 10 * 60_000 }; 7 | -------------------------------------------------------------------------------- /jest.integration.config.js: -------------------------------------------------------------------------------- 1 | const commonConfig = require("./jest.config.js"); 2 | 3 | /** @type {import("ts-jest/dist/types").InitialOptionsTsJest} */ 4 | module.exports = { 5 | ...commonConfig, 6 | testMatch: ["**/?(*.)+(integration).[jt]s?(x)"], 7 | testTimeout: 2 * 60_000, 8 | silent: false, 9 | }; 10 | -------------------------------------------------------------------------------- /jest.resolver.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | 3 | const srcDir = path.join(__dirname, "src"); 4 | 5 | module.exports = function (pkg, opts) { 6 | if (pkg.startsWith("#src/") && opts.basedir.startsWith(srcDir)) { 7 | const targetFile = path.join(srcDir, pkg.substr(5) + ".ts"); 8 | let relativeFile = path.relative(opts.basedir, targetFile); 9 | if (!relativeFile.includes("/")) { 10 | relativeFile = "./" + relativeFile; 11 | } 12 | return opts.defaultResolver(relativeFile, opts); 13 | } 14 | 15 | return opts.defaultResolver(pkg, opts); 16 | }; 17 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@substrate/substrate-tip-bot", 3 | "version": "0.0.0", 4 | "private": true, 5 | "description": "A GitHub bot to submit tips on behalf of the network.", 6 | "author": "Parity Technologies (https://parity.io)", 7 | "license": "MIT", 8 | "homepage": "https://github.com/paritytech/substrate-tip-bot", 9 | "keywords": [ 10 | "probot", 11 | "github", 12 | "probot-app" 13 | ], 14 | "main": "dist/src/bot.js", 15 | "scripts": { 16 | "build": "rimraf dist; tsc", 17 | "build:docker": "docker build -t substrate-tip-bot .", 18 | "fix": "yarn fix:eslint '{*,**/*}.{js,ts}' && yarn fix:prettier '{*,**/*}.json'", 19 | "fix:eslint": "npx eslint --fix", 20 | "fix:prettier": "npx prettier --write", 21 | "lint": "npx eslint --quiet '{*,**/*}.{js,ts}' && npx prettier --check '{*,**/*}.json' && yarn typecheck", 22 | "papi": "papi generate", 23 | "postinstall": "yarn papi", 24 | "dev": "concurrently \"tsc -w\" \"node --env-file=.env --watch --enable-source-maps dist/src/bot.js\"", 25 | "start": "node --env-file=.env --enable-source-maps dist/src/bot.js", 26 | "test": "jest", 27 | "test:e2e": "jest -c jest.e2e.config.js", 28 | "test:integration": "jest --runInBand -c jest.integration.config.js", 29 | "typecheck": "tsc --noEmit" 30 | }, 31 | "imports": { 32 | "#src/*": "./src/*.js" 33 | }, 34 | "dependencies": { 35 | "@eng-automation/integrations": "^4.4.0", 36 | "@eng-automation/js": "^2.2.0", 37 | "@polkadot-api/descriptors": "portal:.papi/descriptors", 38 | "@polkadot-labs/hdkd": "^0.0.6", 39 | "@polkadot-labs/hdkd-helpers": "^0.0.6", 40 | "concurrently": "^8.2.2", 41 | "ethers": "^5.7.2", 42 | "matrix-js-sdk": "^26.1.0", 43 | "polkadot-api": "1.2.0", 44 | "probot": "^12.2.8", 45 | "prom-client": "^14.2.0" 46 | }, 47 | "devDependencies": { 48 | "@eng-automation/js-style": "^3.1.0", 49 | "@eng-automation/testing": "^1.5.2", 50 | "@types/jest": "^29.5.12", 51 | "@types/node": "^20.14.2", 52 | "dotenv": "^16.0.1", 53 | "jest": "^29.7.0", 54 | "rimraf": "^3.0.2", 55 | "rxjs": "^7.8.1", 56 | "smee-client": "^1.2.2", 57 | "testcontainers": "^10.13.0", 58 | "ts-jest": "^29.2.4", 59 | "typescript": "^5.4.5" 60 | }, 61 | "engines": { 62 | "node": "^22 && !22.5.0" 63 | }, 64 | "packageManager": "yarn@4.3.0" 65 | } 66 | -------------------------------------------------------------------------------- /polkadot.e2e.patch: -------------------------------------------------------------------------------- 1 | diff --git a/polkadot/runtime/rococo/src/governance/mod.rs b/polkadot/runtime/rococo/src/governance/mod.rs 2 | index ef2adf60753..218c0a3c837 100644 3 | --- a/polkadot/runtime/rococo/src/governance/mod.rs 4 | +++ b/polkadot/runtime/rococo/src/governance/mod.rs 5 | @@ -35,7 +35,7 @@ mod fellowship; 6 | pub use fellowship::{FellowshipCollectiveInstance, FellowshipReferendaInstance}; 7 | 8 | parameter_types! { 9 | - pub const VoteLockingPeriod: BlockNumber = 7 * DAYS; 10 | + pub const VoteLockingPeriod: BlockNumber = 1; 11 | } 12 | 13 | impl pallet_conviction_voting::Config for Runtime { 14 | diff --git a/polkadot/runtime/rococo/src/governance/tracks.rs b/polkadot/runtime/rococo/src/governance/tracks.rs 15 | index 3765569f183..ed226f4ef65 100644 16 | --- a/polkadot/runtime/rococo/src/governance/tracks.rs 17 | +++ b/polkadot/runtime/rococo/src/governance/tracks.rs 18 | @@ -212,10 +212,10 @@ const TRACKS_DATA: [(u16, pallet_referenda::TrackInfo); 15 19 | name: "small_tipper", 20 | max_deciding: 200, 21 | decision_deposit: 1 * 3 * CENTS, 22 | - prepare_period: 1 * MINUTES, 23 | - decision_period: 14 * MINUTES, 24 | - confirm_period: 4 * MINUTES, 25 | - min_enactment_period: 1 * MINUTES, 26 | + prepare_period: 1, 27 | + decision_period: 1, 28 | + confirm_period: 1, 29 | + min_enactment_period: 1, 30 | min_approval: APP_SMALL_TIPPER, 31 | min_support: SUP_SMALL_TIPPER, 32 | }, 33 | @@ -226,10 +226,10 @@ const TRACKS_DATA: [(u16, pallet_referenda::TrackInfo); 15 34 | name: "big_tipper", 35 | max_deciding: 100, 36 | decision_deposit: 10 * 3 * CENTS, 37 | - prepare_period: 4 * MINUTES, 38 | - decision_period: 14 * MINUTES, 39 | - confirm_period: 12 * MINUTES, 40 | - min_enactment_period: 3 * MINUTES, 41 | + prepare_period: 1, 42 | + decision_period: 1, 43 | + confirm_period: 1, 44 | + min_enactment_period: 1, 45 | min_approval: APP_BIG_TIPPER, 46 | min_support: SUP_BIG_TIPPER, 47 | }, 48 | diff --git a/polkadot/runtime/rococo/src/lib.rs b/polkadot/runtime/rococo/src/lib.rs 49 | index 7309eeead31..35d75fc49cd 100644 50 | --- a/polkadot/runtime/rococo/src/lib.rs 51 | +++ b/polkadot/runtime/rococo/src/lib.rs 52 | @@ -477,7 +477,7 @@ parameter_types! { 53 | } 54 | 55 | parameter_types! { 56 | - pub const SpendPeriod: BlockNumber = 6 * DAYS; 57 | + pub const SpendPeriod: BlockNumber = 1; 58 | pub const Burn: Permill = Permill::from_perthousand(2); 59 | pub const TreasuryPalletId: PalletId = PalletId(*b"py/trsry"); 60 | pub const PayoutSpendPeriod: BlockNumber = 30 * DAYS; 61 | diff --git a/polkadot/runtime/westend/src/governance/mod.rs b/polkadot/runtime/westend/src/governance/mod.rs 62 | index d027f788d71..de6f36ecfce 100644 63 | --- a/polkadot/runtime/westend/src/governance/mod.rs 64 | +++ b/polkadot/runtime/westend/src/governance/mod.rs 65 | @@ -32,7 +32,7 @@ mod tracks; 66 | pub use tracks::TracksInfo; 67 | 68 | parameter_types! { 69 | - pub const VoteLockingPeriod: BlockNumber = 7 * DAYS; 70 | + pub const VoteLockingPeriod: BlockNumber = 1; 71 | } 72 | 73 | impl pallet_conviction_voting::Config for Runtime { 74 | diff --git a/polkadot/runtime/westend/src/governance/tracks.rs b/polkadot/runtime/westend/src/governance/tracks.rs 75 | index 3765569f183..ed226f4ef65 100644 76 | --- a/polkadot/runtime/westend/src/governance/tracks.rs 77 | +++ b/polkadot/runtime/westend/src/governance/tracks.rs 78 | @@ -212,10 +212,10 @@ const TRACKS_DATA: [(u16, pallet_referenda::TrackInfo); 15 79 | name: "small_tipper", 80 | max_deciding: 200, 81 | decision_deposit: 1 * 3 * CENTS, 82 | - prepare_period: 1 * MINUTES, 83 | - decision_period: 14 * MINUTES, 84 | - confirm_period: 4 * MINUTES, 85 | - min_enactment_period: 1 * MINUTES, 86 | + prepare_period: 1, 87 | + decision_period: 1, 88 | + confirm_period: 1, 89 | + min_enactment_period: 1, 90 | min_approval: APP_SMALL_TIPPER, 91 | min_support: SUP_SMALL_TIPPER, 92 | }, 93 | @@ -226,10 +226,10 @@ const TRACKS_DATA: [(u16, pallet_referenda::TrackInfo); 15 94 | name: "big_tipper", 95 | max_deciding: 100, 96 | decision_deposit: 10 * 3 * CENTS, 97 | - prepare_period: 4 * MINUTES, 98 | - decision_period: 14 * MINUTES, 99 | - confirm_period: 12 * MINUTES, 100 | - min_enactment_period: 3 * MINUTES, 101 | + prepare_period: 1, 102 | + decision_period: 1, 103 | + confirm_period: 1, 104 | + min_enactment_period: 1, 105 | min_approval: APP_BIG_TIPPER, 106 | min_support: SUP_BIG_TIPPER, 107 | }, 108 | diff --git a/polkadot/runtime/westend/src/lib.rs b/polkadot/runtime/westend/src/lib.rs 109 | index 369fd308272..733ec32f581 100644 110 | --- a/polkadot/runtime/westend/src/lib.rs 111 | +++ b/polkadot/runtime/westend/src/lib.rs 112 | @@ -666,7 +666,7 @@ impl pallet_fast_unstake::Config for Runtime { 113 | } 114 | 115 | parameter_types! { 116 | - pub const SpendPeriod: BlockNumber = 6 * DAYS; 117 | + pub const SpendPeriod: BlockNumber = 1; 118 | pub const Burn: Permill = Permill::from_perthousand(2); 119 | pub const TreasuryPalletId: PalletId = PalletId(*b"py/trsry"); 120 | pub const PayoutSpendPeriod: BlockNumber = 30 * DAYS; 121 | -------------------------------------------------------------------------------- /src/balance.ts: -------------------------------------------------------------------------------- 1 | import { createClient } from "polkadot-api"; 2 | import { getWsProvider } from "polkadot-api/ws-provider/node"; 3 | import type { Probot } from "probot"; 4 | 5 | import { getChainConfig, getDescriptor, getWsUrl } from "./chain-config"; 6 | import { balanceGauge } from "./metrics"; 7 | import { TipNetwork } from "./types"; 8 | 9 | /** 10 | * The function will update the balances of the tip bot on all networks. 11 | * It will skip the local, development networks. 12 | * This is intended to be executed upon startup of the bot. 13 | * After that, the balances (including local ones) will be updated after a tip is executed. 14 | */ 15 | export const updateAllBalances = async (tipBotAddress: string, log: Probot["log"]): Promise => { 16 | const networks: TipNetwork[] = ["kusama", "polkadot", "rococo", "westend"]; 17 | for (const network of networks) { 18 | log.info(`Checking tip bot balance on ${network}`); 19 | try { 20 | await updateBalance({ network, tipBotAddress }); 21 | } catch (e) { 22 | log.info(`Failed to check balance on ${network}`, e.message); 23 | } 24 | } 25 | }; 26 | 27 | export const updateBalance = async (opts: { network: TipNetwork; tipBotAddress: string }): Promise => { 28 | const { network, tipBotAddress } = opts; 29 | const config = getChainConfig(network); 30 | 31 | const jsonRpcProvider = getWsProvider(getWsUrl(network)); 32 | const client = createClient(jsonRpcProvider); 33 | const api = client.getTypedApi(getDescriptor(network)); 34 | 35 | try { 36 | const { data: balances } = await api.query.System.Account.getValue(tipBotAddress); 37 | const balance = Number(balances.free / 10n ** BigInt(config.decimals)); 38 | balanceGauge.set({ network }, balance); 39 | } finally { 40 | client.destroy(); 41 | } 42 | }; 43 | -------------------------------------------------------------------------------- /src/bot-handle-comment.ts: -------------------------------------------------------------------------------- 1 | import { github } from "@eng-automation/integrations"; 2 | import { GitHubInstance } from "@eng-automation/integrations/dist/github/types"; 3 | import { envVar } from "@eng-automation/js"; 4 | import { IssueCommentCreatedEvent } from "@octokit/webhooks-types"; 5 | import { ss58Address } from "@polkadot-labs/hdkd-helpers"; 6 | 7 | import { updateBalance } from "./balance"; 8 | import { matrixNotifyOnFailure, matrixNotifyOnNewTip } from "./matrix"; 9 | import { recordTip } from "./metrics"; 10 | import { tipUser, tipUserLink } from "./tip"; 11 | import { updatePolkassemblyPost } from "./tip-opengov"; 12 | import { GithubReactionType, State, TipRequest, TipResult } from "./types"; 13 | import { formatTipSize, getTipSize, parseContributorAccount } from "./util"; 14 | 15 | type OnIssueCommentResult = 16 | | { success: true; message: string } 17 | | { success: true; message: string; tipRequest: TipRequest; tipResult: Extract } 18 | | { success: false; errorMessage: string }; 19 | 20 | export const handleIssueCommentCreated = async (state: State, event: IssueCommentCreatedEvent): Promise => { 21 | const [botMention] = event.comment.body.split(" ") as (string | undefined)[]; 22 | 23 | // The bot only triggers on creation of a new comment on a pull request. 24 | if (!event.issue.pull_request || event.action !== "created" || !botMention?.startsWith("/tip")) { 25 | return; 26 | } 27 | 28 | // The "Unsafe assignment of an error typed value" error here goes deep into octokit types, that are full of `any`s 29 | // I wasn't able to get around it 30 | // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment 31 | const appOctokitInstance: GitHubInstance = await github.getInstance({ 32 | authType: "app", 33 | appId: envVar("GITHUB_APP_ID"), 34 | privateKey: envVar("GITHUB_PRIVATE_KEY"), 35 | ...(process.env.GITHUB_BASE_URL && { apiEndpoint: envVar("GITHUB_BASE_URL") }), 36 | }); 37 | 38 | const tipRequester = event.comment.user.login; 39 | const installationId = ( 40 | await github.getRepoInstallation( 41 | { 42 | owner: event.repository.owner.login, 43 | repo: event.repository.name, 44 | }, 45 | { octokitInstance: appOctokitInstance }, 46 | ) 47 | ).id; 48 | 49 | // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment 50 | const repositoryOctokitInstance: GitHubInstance = await github.getInstance({ 51 | authType: "installation", 52 | appId: envVar("GITHUB_APP_ID"), 53 | installationId: String(installationId), 54 | privateKey: envVar("GITHUB_PRIVATE_KEY"), 55 | ...(process.env.GITHUB_BASE_URL && { apiEndpoint: envVar("GITHUB_BASE_URL") }), 56 | }); 57 | 58 | const respondParams = { 59 | owner: event.repository.owner.login, 60 | repo: event.repository.name, 61 | issue_number: event.issue.number, 62 | }; 63 | 64 | const githubComment = async (body: string) => 65 | await github.createComment({ ...respondParams, body }, { octokitInstance: repositoryOctokitInstance }); 66 | const githubEmojiReaction = async (reaction: GithubReactionType) => 67 | await github.createReactionForIssueComment( 68 | { ...respondParams, comment_id: event.comment.id, content: reaction }, 69 | { octokitInstance: repositoryOctokitInstance }, 70 | ); 71 | 72 | await githubEmojiReaction("eyes"); 73 | await matrixNotifyOnNewTip(state.matrix, event); 74 | let result: OnIssueCommentResult; 75 | try { 76 | // important: using appOctokitInstance to handleTipRequest, and getting right installation there, 77 | // as we'll be querying our org team members with that, and repo installation permissions may not work. 78 | result = await handleTipRequest(state, event, tipRequester, appOctokitInstance); 79 | if (result.success) { 80 | await githubComment(result.message); 81 | await githubEmojiReaction("rocket"); 82 | } else { 83 | await githubComment(result.errorMessage); 84 | await githubEmojiReaction("confused"); 85 | await matrixNotifyOnFailure(state.matrix, event, { tagMaintainers: false }); 86 | } 87 | } catch (e) { 88 | state.bot.log.error(e.message); 89 | await githubComment( 90 | `@${tipRequester} Could not submit tip :( The team has been notified. Alternatively open an issue [here](https://github.com/paritytech/substrate-tip-bot/issues/new).`, 91 | ); 92 | await githubEmojiReaction("confused"); 93 | await matrixNotifyOnFailure(state.matrix, event, { tagMaintainers: true }); 94 | return; 95 | } 96 | 97 | if (result.success && state.polkassembly && "tipResult" in result && result.tipResult.referendumNumber) { 98 | try { 99 | const { url } = await updatePolkassemblyPost({ 100 | polkassembly: state.polkassembly, 101 | referendumId: result.tipResult.referendumNumber, 102 | tipRequest: result.tipRequest, 103 | track: result.tipResult.track, 104 | log: state.bot.log, 105 | }); 106 | await githubComment(`The referendum has appeared on [Polkassembly](${url}).`); 107 | } catch (e) { 108 | state.bot.log.error( 109 | `Failed to update the Polkasssembly metadata; referendumId: ${result.tipResult.referendumNumber}`, 110 | result.tipRequest, 111 | ); 112 | state.bot.log.error(e.message); 113 | await matrixNotifyOnFailure(state.matrix, event, { 114 | tagMaintainers: true, 115 | failedItem: "Polkassembly post update", 116 | }); 117 | } 118 | } 119 | }; 120 | 121 | export const handleTipRequest = async ( 122 | state: State, 123 | event: IssueCommentCreatedEvent, 124 | tipRequester: string, 125 | appOctokitInstance: github.GitHubInstance, 126 | ): Promise => { 127 | const { allowedGitHubOrg, allowedGitHubTeam, bot } = state; 128 | 129 | const [_, tipSizeInput] = event.comment.body.split(" ") as (string | undefined)[]; 130 | const pullRequestBody = event.issue.body; 131 | const pullRequestUrl = event.issue.html_url; 132 | const contributorLogin = event.issue.user.login; 133 | const pullRequestNumber = event.issue.number; 134 | const pullRequestRepo = event.repository.name; 135 | const pullRequestOwner = event.repository.owner.login; 136 | 137 | if (tipRequester === contributorLogin) { 138 | return { success: false, errorMessage: `@${tipRequester} Contributor and tipper cannot be the same person!` }; 139 | } 140 | 141 | const appInstallations = await github.getAppInstallations({}, { octokitInstance: appOctokitInstance }); 142 | const approversOrgInstallation = appInstallations.find( 143 | (installation) => installation.account?.login === allowedGitHubOrg, 144 | ); 145 | 146 | if (approversOrgInstallation === undefined) { 147 | return { success: false, errorMessage: `GitHub application is not installed to ${allowedGitHubOrg} org` }; 148 | } 149 | 150 | // The "Unsafe assignment of an error typed value" error here goes deep into octokit types, that are full of `any`s 151 | // I wasn't able to get around it 152 | // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment 153 | const octokitInstance: GitHubInstance = await github.getInstance({ 154 | authType: "installation", 155 | appId: envVar("GITHUB_APP_ID"), 156 | installationId: String(approversOrgInstallation.id), 157 | privateKey: envVar("GITHUB_PRIVATE_KEY"), 158 | ...(process.env.GITHUB_BASE_URL && { apiEndpoint: envVar("GITHUB_BASE_URL") }), 159 | }); 160 | 161 | const userBio = (await octokitInstance.rest.users.getByUsername({ username: contributorLogin })).data.bio; 162 | const contributorAccount = parseContributorAccount([pullRequestBody, userBio]); 163 | if ("error" in contributorAccount) { 164 | // Contributor is tagged because it is up to him to properly prepare his address. 165 | return { success: false, errorMessage: `@${contributorLogin} ${contributorAccount.error}` }; 166 | } 167 | 168 | const tipSize = getTipSize(tipSizeInput?.trim()); 169 | if (typeof tipSize == "object" && "error" in tipSize) { 170 | return { success: false, errorMessage: `@${tipRequester} ${tipSize.error}` }; 171 | } 172 | 173 | const tipRequest: TipRequest = { 174 | contributor: { githubUsername: contributorLogin, account: contributorAccount }, 175 | pullRequestNumber, 176 | pullRequestRepo, 177 | pullRequestOwner, 178 | tip: { size: tipSize }, 179 | }; 180 | 181 | bot.log( 182 | `Valid command!\n${tipRequester} wants to tip ${contributorLogin} (${contributorAccount.address} on ${ 183 | contributorAccount.network 184 | }) a ${formatTipSize(tipRequest)} tip for pull request ${pullRequestUrl}.`, 185 | ); 186 | 187 | if ( 188 | !(await github.isGithubTeamMember( 189 | { org: allowedGitHubOrg, team: allowedGitHubTeam, username: tipRequester }, 190 | { octokitInstance }, 191 | )) 192 | ) { 193 | let createReferendumLink: string | undefined; 194 | try { 195 | const tipLink = await tipUserLink(state, tipRequest); 196 | if (!tipLink.success) { 197 | throw new Error(tipLink.errorMessage); 198 | } 199 | createReferendumLink = tipLink.extrinsicCreationLink; 200 | } catch (e) { 201 | bot.log.error("Failed to encode and create a link to tip referendum creation."); 202 | bot.log.error(e.message); 203 | } 204 | 205 | let message = 206 | `Only members of [${allowedGitHubOrg}/${allowedGitHubTeam}](https://github.com/orgs/${allowedGitHubOrg}/teams/${allowedGitHubTeam}) ` + 207 | `have permission to request the creation of the tip referendum from the bot.\n\n`; 208 | message += `However, you can create the tip referendum yourself using [Polkassembly](https://wiki.polkadot.network/docs/learn-polkadot-opengov-treasury#submit-treasury-proposal-via-polkassembly)`; 209 | return { 210 | success: true, 211 | message: createReferendumLink ? message + ` or [PolkadotJS Apps](${createReferendumLink}).` : message + ".", 212 | }; 213 | } 214 | 215 | const tipResult = await tipUser(state, tipRequest); 216 | 217 | // The user doesn't need to wait until we update metrics and balances, so launching it separately. 218 | void (async () => { 219 | try { 220 | recordTip({ tipRequest, tipResult }); 221 | await updateBalance({ 222 | network: tipRequest.contributor.account.network, 223 | tipBotAddress: ss58Address(state.botTipAccount.publicKey), 224 | }); 225 | } catch (e) { 226 | bot.log.error(e.message); 227 | } 228 | })(); 229 | 230 | if (tipResult.success) { 231 | const numberInfo = 232 | tipResult.referendumNumber !== null ? `Referendum number: **${tipResult.referendumNumber}**.` : ""; 233 | return { 234 | success: true, 235 | tipRequest, 236 | tipResult, 237 | message: `@${tipRequester} A referendum for a ${formatTipSize( 238 | tipRequest, 239 | )} tip was successfully submitted for @${contributorLogin} (${contributorAccount.address} on ${ 240 | contributorAccount.network 241 | }).\n\n${numberInfo}\n![tip](https://c.tenor.com/GdyQm7LX3h4AAAAi/mlady-fedora.gif)`, 242 | }; 243 | } else { 244 | return { success: false, errorMessage: tipResult.errorMessage }; 245 | } 246 | }; 247 | -------------------------------------------------------------------------------- /src/bot-initialize.ts: -------------------------------------------------------------------------------- 1 | import { envVar } from "@eng-automation/js"; 2 | import { sr25519CreateDerive } from "@polkadot-labs/hdkd"; 3 | import { entropyToMiniSecret, mnemonicToEntropy, parseSuri, ss58Address } from "@polkadot-labs/hdkd-helpers"; 4 | import { createClient } from "matrix-js-sdk"; 5 | import * as process from "node:process"; 6 | import { PolkadotSigner } from "polkadot-api"; 7 | import { getPolkadotSigner } from "polkadot-api/signer"; 8 | import { ApplicationFunction, Context, Probot } from "probot"; 9 | 10 | import { updateAllBalances } from "./balance"; 11 | import { handleIssueCommentCreated } from "./bot-handle-comment"; 12 | import { addMetricsRoute } from "./metrics"; 13 | import { Polkassembly } from "./polkassembly/polkassembly"; 14 | import { State } from "./types"; 15 | 16 | type AsyncApplicationFunction = ( 17 | ...params: Parameters 18 | ) => Promise>; 19 | 20 | export const generateSigner = (accountSeed: string): PolkadotSigner => { 21 | const suri = parseSuri(accountSeed); 22 | 23 | const entropy = mnemonicToEntropy(suri.phrase); 24 | const miniSecret = entropyToMiniSecret(entropy); 25 | const hdkdKeyPair = sr25519CreateDerive(miniSecret)(suri.paths); 26 | 27 | return getPolkadotSigner(hdkdKeyPair.publicKey, "Sr25519", (input) => hdkdKeyPair.sign(input)); 28 | }; 29 | 30 | export const botInitialize: AsyncApplicationFunction = async (bot: Probot, { getRouter }) => { 31 | bot.log.info("Loading tip bot..."); 32 | const router = getRouter?.("/tip-bot"); 33 | if (router) { 34 | addMetricsRoute(router); 35 | } else { 36 | bot.log.warn("No router received from the probot library, metrics were not added."); 37 | } 38 | 39 | const botTipAccount = generateSigner(envVar("ACCOUNT_SEED")); 40 | const state: State = { 41 | bot, 42 | allowedGitHubOrg: envVar("APPROVERS_GH_ORG"), 43 | allowedGitHubTeam: envVar("APPROVERS_GH_TEAM"), 44 | botTipAccount, 45 | polkassembly: (() => { 46 | if (!process.env.POLKASSEMBLY_ENDPOINT) { 47 | // convenient for local development, and tests 48 | bot.log.warn("POLKASSEMBLY_ENDPOINT is not set; polkassembly integration is disabled"); 49 | return undefined; 50 | } 51 | return new Polkassembly( 52 | envVar("POLKASSEMBLY_ENDPOINT"), 53 | { type: "polkadot", keyringPair: botTipAccount }, 54 | bot.log, 55 | ); 56 | })(), 57 | matrix: (() => { 58 | if (!process.env.MATRIX_ACCESS_TOKEN) { 59 | // convenient for local development, and tests 60 | bot.log.warn("MATRIX_ACCESS_TOKEN is not set; matrix notifications are disabled"); 61 | return undefined; 62 | } 63 | return { 64 | client: createClient({ 65 | accessToken: envVar("MATRIX_ACCESS_TOKEN"), 66 | baseUrl: envVar("MATRIX_SERVER_URL"), 67 | localTimeoutMs: 10000, 68 | }), 69 | roomId: envVar("MATRIX_ROOM_ID"), 70 | }; 71 | })(), 72 | }; 73 | 74 | bot.log.info("Tip bot was loaded!"); 75 | 76 | bot.on("issue_comment.created", async (context: Context<"issue_comment.created">) => { 77 | await handleIssueCommentCreated(state, context.payload); 78 | }); 79 | 80 | try { 81 | bot.log.info("Loading bot balances across all networks..."); 82 | const address = ss58Address(botTipAccount.publicKey); 83 | await updateAllBalances(address, bot.log); 84 | bot.log.info("Updated bot balances across all networks!"); 85 | } catch (e) { 86 | bot.log.error(e.message); 87 | } 88 | }; 89 | -------------------------------------------------------------------------------- /src/bot.ts: -------------------------------------------------------------------------------- 1 | import { envVar } from "@eng-automation/js"; 2 | import { ApplicationFunction, run } from "probot"; 3 | 4 | import { botInitialize } from "./bot-initialize"; 5 | 6 | if (process.env.PRIVATE_KEY_BASE64 && !process.env.PRIVATE_KEY) { 7 | process.env.PRIVATE_KEY = Buffer.from(envVar("PRIVATE_KEY_BASE64"), "base64").toString(); 8 | } 9 | 10 | // Aligning environment between Probot and @eng-automation/integrations 11 | process.env.GITHUB_APP_ID = process.env.APP_ID; 12 | process.env.GITHUB_AUTH_TYPE = "app"; 13 | process.env.GITHUB_PRIVATE_KEY = process.env.PRIVATE_KEY; 14 | 15 | /* Probot types do not accept async function type, 16 | but it seems that the actual code handles it properly. */ 17 | void run(botInitialize as ApplicationFunction); 18 | -------------------------------------------------------------------------------- /src/chain-config.ts: -------------------------------------------------------------------------------- 1 | import { kusama, polkadot, rococo, westend } from "@polkadot-api/descriptors"; 2 | import { readFileSync } from "fs"; 3 | 4 | import { ChainConfig, TipNetwork } from "./types"; 5 | 6 | const papiConfig = JSON.parse(readFileSync(".papi/polkadot-api.json", "utf-8")) as { 7 | entries: { 8 | [p in TipNetwork]: { 9 | wsUrl: string; 10 | chain: string; 11 | metadata: string; 12 | }; 13 | }; 14 | }; 15 | 16 | export function getWsUrl(network: TipNetwork): string { 17 | const local = Boolean(process.env.LOCAL_NETWORKS); 18 | 19 | switch (network) { 20 | case "kusama": { 21 | return local ? "ws://127.0.0.1:9901" : papiConfig.entries.kusama.wsUrl; 22 | } 23 | case "polkadot": { 24 | return local ? "ws://127.0.0.1:9900" : papiConfig.entries.polkadot.wsUrl; 25 | } 26 | case "rococo": { 27 | if (process.env.INTEGRATION_TEST) { 28 | return "ws://localrococo:9945"; // neighbouring container name 29 | } 30 | return local ? "ws://127.0.0.1:9902" : papiConfig.entries.rococo.wsUrl; 31 | } 32 | case "westend": { 33 | if (process.env.INTEGRATION_TEST) { 34 | return "ws://localwestend:9945"; // neighbouring container name 35 | } 36 | return local ? "ws://127.0.0.1:9903" : papiConfig.entries.westend.wsUrl; 37 | } 38 | default: { 39 | const exhaustivenessCheck: never = network; 40 | throw new Error( 41 | // eslint-disable-next-line @typescript-eslint/restrict-template-expressions 42 | `Network is not handled properly in tipUser: ${exhaustivenessCheck}`, 43 | ); 44 | } 45 | } 46 | } 47 | 48 | export type ChainDescriptor = Chain extends "polkadot" 49 | ? typeof polkadot 50 | : Chain extends "kusama" 51 | ? typeof kusama 52 | : Chain extends "rococo" 53 | ? typeof rococo 54 | : Chain extends "westend" 55 | ? typeof westend 56 | : never; 57 | 58 | export function getDescriptor(network: Chain): ChainDescriptor { 59 | const networks: { [Key in TipNetwork]: ChainDescriptor } = { 60 | polkadot, 61 | kusama, 62 | rococo, 63 | westend, 64 | }; 65 | 66 | return networks[network] as ChainDescriptor; 67 | } 68 | 69 | type Constants = Omit; 70 | export const kusamaConstants: Constants = { 71 | decimals: 12n, 72 | currencySymbol: "KSM", 73 | 74 | /** 75 | * Source of the calculation: 76 | * https://github.com/paritytech/polkadot/blob/e164da65873f11bf8c583e81f6d82c21b005cfe4/runtime/kusama/src/governance/origins.rs#L172 77 | * https://github.com/paritytech/polkadot/blob/e164da65873f11bf8c583e81f6d82c21b005cfe4/runtime/kusama/constants/src/lib.rs#L29 78 | */ 79 | smallTipperMaximum: 8.33, 80 | 81 | /** 82 | * Source of the calculation: 83 | * https://github.com/paritytech/polkadot/blob/e164da65873f11bf8c583e81f6d82c21b005cfe4/runtime/kusama/src/governance/origins.rs#L173 84 | * https://github.com/paritytech/polkadot/blob/e164da65873f11bf8c583e81f6d82c21b005cfe4/runtime/kusama/constants/src/lib.rs#L31 85 | */ 86 | bigTipperMaximum: 33.33, 87 | 88 | /** 89 | * These are arbitrary values, can be changed at any time. 90 | */ 91 | namedTips: { small: 4n, medium: 16n, large: 30n }, 92 | }; 93 | 94 | export const polkadotConstants: Constants = { 95 | decimals: 10n, 96 | currencySymbol: "DOT", 97 | 98 | /** 99 | * Source of the calculation: 100 | * https://github.com/paritytech/polkadot/blob/e164da65873f11bf8c583e81f6d82c21b005cfe4/runtime/polkadot/src/governance/origins.rs#L143 101 | * https://github.com/paritytech/polkadot/blob/e164da65873f11bf8c583e81f6d82c21b005cfe4/runtime/polkadot/constants/src/lib.rs#L31 102 | */ 103 | smallTipperMaximum: 250, 104 | 105 | /** 106 | * Source of the calculation: 107 | * https://github.com/paritytech/polkadot/blob/e164da65873f11bf8c583e81f6d82c21b005cfe4/runtime/polkadot/src/governance/origins.rs#L144 108 | * https://github.com/paritytech/polkadot/blob/e164da65873f11bf8c583e81f6d82c21b005cfe4/runtime/polkadot/constants/src/lib.rs#L32 109 | */ 110 | bigTipperMaximum: 1000, 111 | 112 | /** 113 | * These are arbitrary values, can be changed at any time. 114 | */ 115 | namedTips: { small: 20n, medium: 80n, large: 150n }, 116 | }; 117 | 118 | export const rococoConstants: Constants = { 119 | decimals: 12n, 120 | currencySymbol: "ROC", 121 | 122 | /** 123 | * Source of the calculation: 124 | * https://github.com/paritytech/polkadot-sdk/blob/d7862aa8c9b4f8be1d4330bc11c742bf48d407f6/polkadot/runtime/rococo/src/governance/origins.rs#L172 125 | * https://github.com/paritytech/polkadot-sdk/blob/d7862aa8c9b4f8be1d4330bc11c742bf48d407f6/polkadot/runtime/rococo/constants/src/lib.rs#L29 126 | */ 127 | smallTipperMaximum: 0.025, 128 | 129 | /** 130 | * Source of the calculation: 131 | * https://github.com/paritytech/polkadot-sdk/blob/d7862aa8c9b4f8be1d4330bc11c742bf48d407f6/polkadot/runtime/rococo/src/governance/origins.rs#L173 132 | * https://github.com/paritytech/polkadot-sdk/blob/d7862aa8c9b4f8be1d4330bc11c742bf48d407f6/polkadot/runtime/rococo/constants/src/lib.rs#L30 133 | */ 134 | bigTipperMaximum: 3.333, 135 | 136 | /** 137 | * These are arbitrary values, can be changed at any time. 138 | */ 139 | namedTips: { small: 1n, medium: 2n, large: 3n }, 140 | }; 141 | 142 | export const westendConstants: Constants = { 143 | decimals: 12n, 144 | currencySymbol: "WND", 145 | 146 | /** 147 | * Source of the calculation: 148 | * https://github.com/paritytech/polkadot-sdk/blob/d7862aa8c9b4f8be1d4330bc11c742bf48d407f6/polkadot/runtime/westend/src/governance/origins.rs#L172 149 | * https://github.com/paritytech/polkadot-sdk/blob/d7862aa8c9b4f8be1d4330bc11c742bf48d407f6/polkadot/runtime/westend/constants/src/lib.rs#L29 150 | */ 151 | smallTipperMaximum: 0.025, 152 | 153 | /** 154 | * Source of the calculation: 155 | * https://github.com/paritytech/polkadot-sdk/blob/d7862aa8c9b4f8be1d4330bc11c742bf48d407f6/polkadot/runtime/westend/src/governance/origins.rs#L173 156 | * https://github.com/paritytech/polkadot-sdk/blob/d7862aa8c9b4f8be1d4330bc11c742bf48d407f6/polkadot/runtime/westend/constants/src/lib.rs#L30 157 | */ 158 | bigTipperMaximum: 3.333, 159 | 160 | /** 161 | * These are arbitrary values, can be changed at any time. 162 | */ 163 | namedTips: { small: 1n, medium: 2n, large: 3n }, 164 | }; 165 | 166 | export function getChainConfig(network: TipNetwork): ChainConfig { 167 | switch (network) { 168 | case "kusama": { 169 | return kusamaConstants; 170 | } 171 | case "polkadot": { 172 | return polkadotConstants; 173 | } 174 | case "rococo": { 175 | return rococoConstants; 176 | } 177 | case "westend": { 178 | return westendConstants; 179 | } 180 | default: { 181 | const exhaustivenessCheck: never = network; 182 | throw new Error( 183 | // eslint-disable-next-line @typescript-eslint/restrict-template-expressions 184 | `Network is not handled properly in tipUser: ${exhaustivenessCheck}`, 185 | ); 186 | } 187 | } 188 | } 189 | -------------------------------------------------------------------------------- /src/matrix.ts: -------------------------------------------------------------------------------- 1 | import { IssueCommentCreatedEvent } from "@octokit/webhooks-types"; 2 | 3 | import { State } from "./types"; 4 | import { teamMatrixHandles } from "./util"; 5 | 6 | export const sendMatrixMessage = async ( 7 | matrix: State["matrix"], 8 | opts: { text: string; html: string }, 9 | ): Promise => { 10 | await matrix?.client.sendMessage(matrix.roomId, { 11 | body: opts.text, 12 | format: "org.matrix.custom.html", 13 | formatted_body: opts.html, 14 | msgtype: "m.text", 15 | }); 16 | }; 17 | 18 | export const matrixNotifyOnNewTip = async (matrix: State["matrix"], event: IssueCommentCreatedEvent): Promise => { 19 | await sendMatrixMessage(matrix, { 20 | text: `A new tip has been requested: ${event.comment.html_url}`, 21 | html: `A new tip has been requested.`, 22 | }); 23 | }; 24 | 25 | export const matrixNotifyOnFailure = async ( 26 | matrix: State["matrix"], 27 | event: IssueCommentCreatedEvent, 28 | opts: { tagMaintainers: boolean; failedItem?: string }, 29 | ): Promise => { 30 | const tag = opts.tagMaintainers ? `${teamMatrixHandles.join(" ")} ` : ""; 31 | const failedItem = opts.failedItem ?? "A tip"; 32 | await sendMatrixMessage(matrix, { 33 | text: `${tag}${failedItem} has failed: ${event.comment.html_url}`, 34 | html: `${tag}${failedItem} has failed!`, 35 | }); 36 | }; 37 | -------------------------------------------------------------------------------- /src/metrics.ts: -------------------------------------------------------------------------------- 1 | import type { Router } from "express"; 2 | import promClient from "prom-client"; 3 | 4 | import { TipRequest, TipResult } from "./types"; 5 | 6 | const prefix = "tip_bot_"; 7 | 8 | promClient.register.setDefaultLabels({ team: "opstooling" }); 9 | promClient.collectDefaultMetrics({ prefix }); 10 | 11 | export const tipCounter = new promClient.Counter({ 12 | name: `${prefix}tips_handled_total`, 13 | help: "Amount of all tips successfully proposed on-chain.", 14 | labelNames: ["network", "governance", "result"] as const, 15 | }); 16 | 17 | export const recordTip = (opts: { tipRequest: TipRequest; tipResult: TipResult }): void => { 18 | const { tipRequest, tipResult } = opts; 19 | tipCounter.inc({ network: tipRequest.contributor.account.network, result: tipResult.success ? "ok" : "fail" }); 20 | }; 21 | 22 | export const balanceGauge = new promClient.Gauge({ 23 | name: `${prefix}balance`, 24 | help: "Balance of the tip bot account", 25 | labelNames: ["network"] as const, 26 | }); 27 | 28 | export const addMetricsRoute = (router: Router): void => { 29 | router.get("/metrics", (req, res) => { 30 | promClient.register 31 | .metrics() 32 | .then((metrics) => { 33 | res.status(200); 34 | res.type("text/plain"); 35 | res.send(metrics); 36 | }) 37 | .catch((error) => { 38 | res.status(500); 39 | res.send(error.message); 40 | }); 41 | }); 42 | 43 | router.get("/health", (req, res) => { 44 | res.send("OK"); 45 | }); 46 | }; 47 | -------------------------------------------------------------------------------- /src/polkassembly/polkassembly-moonbase.integration.ts: -------------------------------------------------------------------------------- 1 | import { logMock } from "#src/testUtil"; 2 | import { formatReason } from "#src/util"; 3 | import { Wallet } from "ethers"; 4 | 5 | import { Polkassembly } from "./polkassembly"; 6 | 7 | /** 8 | * This is a test suite that uses the production (non-test) Polkassembly API, 9 | * and a Moonbase Alpha testnet to perform a single test case that 10 | * creates a referendum and edits the metadata on Polkassembly. 11 | * 12 | * Moonbase is an EVM-compatible chain, so we're using an Ethereum signer. 13 | * Currently, it's the only testnet with OpenGov and Polkassembly support. 14 | * Related: https://github.com/paritytech/substrate-tip-bot/issues/46 15 | * 16 | * The tests are mostly manual because the code doesn't support sending 17 | * Ethereum-signed blockchain transactions (only Ethereum-signed Polkassembly API calls). 18 | * Also, Moonbase Alpha doesn't have the tipper tracks. 19 | * 20 | * To run: 21 | * 1. Create a Moonbase Alpha account 22 | * 2. Fund it (upwards of 20 DEV are needed) 23 | * 3. Manually (in polkadot.js.org/apps) create a preimage and a referendum. 24 | * Use any tack, for example Root. Tipper tracks are not available. 25 | * 4. Un-skip the test, and edit the variables below. 26 | */ 27 | describe("Polkassembly with production API and Moonbase Alpha testnet", () => { 28 | let polkassembly: Polkassembly; 29 | const moonbaseMnemonic: string | undefined = undefined; // Edit before running. 30 | const manuallyCreatedReferendumId: number | undefined = undefined; // Edit before running 31 | 32 | beforeAll(() => { 33 | if (moonbaseMnemonic === undefined || manuallyCreatedReferendumId === undefined) { 34 | throw new Error("Variables needed. Read description above."); 35 | } 36 | const wallet = Wallet.fromMnemonic(moonbaseMnemonic); 37 | polkassembly = new Polkassembly("https://api.polkassembly.io/api/v1/", { type: "ethereum", wallet }, logMock); 38 | }); 39 | 40 | test.skip("Edits a metadata of an existing referendum", async () => { 41 | await polkassembly.loginOrSignup("moonbase"); 42 | 43 | const content = formatReason( 44 | { 45 | pullRequestOwner: "exampleorg", 46 | pullRequestRepo: "examplerepo", 47 | pullRequestNumber: 1, 48 | contributor: { githubUsername: "exampleuser", account: { address: "xyz", network: "kusama" } }, 49 | tip: { size: "medium" }, 50 | }, 51 | { markdown: true }, 52 | ); 53 | 54 | await polkassembly.editPost("moonbase", { 55 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 56 | postId: manuallyCreatedReferendumId!, 57 | proposalType: "referendums_v2", 58 | content: content, 59 | title: "A mock referendum", 60 | }); 61 | }); 62 | }); 63 | -------------------------------------------------------------------------------- /src/polkassembly/polkassembly.integration.ts: -------------------------------------------------------------------------------- 1 | import { entropyToMnemonic } from "@polkadot-labs/hdkd-helpers"; 2 | import { generateSigner } from "#src/bot-initialize"; 3 | import { logMock } from "#src/testUtil"; 4 | import crypto from "crypto"; 5 | import { PolkadotSigner } from "polkadot-api"; 6 | 7 | import { Polkassembly } from "./polkassembly"; 8 | 9 | const network = "moonbase"; 10 | 11 | describe("Polkassembly with a test endpoint", () => { 12 | let keyringPair: PolkadotSigner; 13 | let polkassembly: Polkassembly; 14 | 15 | beforeEach(() => { 16 | // A random account for every test. 17 | keyringPair = generateSigner(entropyToMnemonic(crypto.randomBytes(32))); 18 | polkassembly = new Polkassembly("https://test.polkassembly.io/api/v1/", { type: "polkadot", keyringPair }, logMock); 19 | }); 20 | 21 | test("Can produce a signature", async () => { 22 | await polkassembly.signMessage("something"); 23 | }); 24 | 25 | test("We are not logged in initially", () => { 26 | expect(polkassembly.loggedIn).toBeFalsy(); 27 | }); 28 | 29 | test("We cannot log in without signing up first", async () => { 30 | await expect(() => polkassembly.login(network)).rejects.toThrowError( 31 | "Please sign up prior to logging in with a web3 address", 32 | ); 33 | }); 34 | 35 | test("Can sign up", async () => { 36 | await polkassembly.signup(network); 37 | expect(polkassembly.loggedIn).toBeTruthy(); 38 | }); 39 | 40 | test("Can log in and logout, having signed up", async () => { 41 | await polkassembly.signup(network); 42 | expect(polkassembly.loggedIn).toBeTruthy(); 43 | 44 | polkassembly.logout(); 45 | expect(polkassembly.loggedIn).toBeFalsy(); 46 | 47 | await polkassembly.login(network); 48 | expect(polkassembly.loggedIn).toBeTruthy(); 49 | }); 50 | 51 | test("Cannot sign up twice", async () => { 52 | await polkassembly.signup(network); 53 | expect(polkassembly.loggedIn).toBeTruthy(); 54 | 55 | polkassembly.logout(); 56 | expect(polkassembly.loggedIn).toBeFalsy(); 57 | 58 | await expect(() => polkassembly.signup(network)).rejects.toThrowError( 59 | "There is already an account associated with this address, you cannot sign-up with this address", 60 | ); 61 | expect(polkassembly.loggedIn).toBeFalsy(); 62 | }); 63 | 64 | test("Login-or-signup handles it all", async () => { 65 | expect(polkassembly.loggedIn).toBeFalsy(); 66 | 67 | // Will sign up. 68 | await polkassembly.loginOrSignup(network); 69 | expect(polkassembly.loggedIn).toBeTruthy(); 70 | 71 | // Won't throw an error when trying again. 72 | await polkassembly.loginOrSignup(network); 73 | expect(polkassembly.loggedIn).toBeTruthy(); 74 | 75 | // Can log out. 76 | polkassembly.logout(); 77 | expect(polkassembly.loggedIn).toBeFalsy(); 78 | 79 | // Can log back in. 80 | await polkassembly.loginOrSignup(network); 81 | expect(polkassembly.loggedIn).toBeTruthy(); 82 | 83 | // Can relog to a different network. 84 | await polkassembly.loginOrSignup("kusama"); 85 | expect(polkassembly.loggedIn).toBeTruthy(); 86 | }); 87 | 88 | test("Can retrieve a last referendum number on a track", async () => { 89 | const result = await polkassembly.getLastReferendumNumber("moonbase", 0); 90 | expect(typeof result).toEqual("number"); 91 | expect(result).toBeGreaterThan(0); 92 | }); 93 | }); 94 | -------------------------------------------------------------------------------- /src/polkassembly/polkassembly.ts: -------------------------------------------------------------------------------- 1 | import { ss58Address } from "@polkadot-labs/hdkd-helpers"; 2 | import { Wallet } from "ethers"; 3 | import { PolkadotSigner } from "polkadot-api"; 4 | import type { Probot } from "probot"; 5 | 6 | const headers = { "Content-Type": "application/json" }; 7 | 8 | export class Polkassembly { 9 | private loggedInData: { token: string; network: string } | undefined = undefined; 10 | 11 | private get token(): string | undefined { 12 | return this.loggedInData?.token; 13 | } 14 | 15 | private get network(): string | undefined { 16 | return this.loggedInData?.network; 17 | } 18 | 19 | constructor( 20 | private endpoint: string, 21 | private signer: { type: "polkadot"; keyringPair: PolkadotSigner } | { type: "ethereum"; wallet: Wallet }, // Ethereum type is used for EVM chains. 22 | private log: Probot["log"], 23 | ) {} 24 | 25 | public get loggedIn(): boolean { 26 | return this.loggedInData !== undefined; 27 | } 28 | 29 | public get address(): string { 30 | return this.signer.type === "polkadot" 31 | ? ss58Address(this.signer.keyringPair.publicKey) 32 | : this.signer.wallet.address; 33 | } 34 | 35 | public async signup(network: string): Promise { 36 | if (this.loggedIn && this.network === network) { 37 | this.log("Already logged in to Polkassembly - signup is skipped."); 38 | return; 39 | } 40 | if (this.loggedIn && this.network !== network) { 41 | this.log("Already logged in to Polkassembly but on different network - signing up and relogging."); 42 | this.logout(); 43 | } 44 | this.log("Signing up to Polkassembly..."); 45 | const signupStartResponse = await fetch(`${this.endpoint}/auth/actions/addressSignupStart`, { 46 | headers: { ...headers, "x-network": network }, 47 | method: "POST", 48 | body: JSON.stringify({ address: this.address }), 49 | }); 50 | if (!signupStartResponse.ok) { 51 | this.log.error(`addressSignupStart failed with status code ${signupStartResponse.status}`); 52 | throw new Error(await signupStartResponse.text()); 53 | } 54 | const signupStartBody = (await signupStartResponse.json()) as { signMessage: string }; 55 | 56 | const signupResponse = await fetch(`${this.endpoint}/auth/actions/addressSignupConfirm`, { 57 | headers: { ...headers, "x-network": network }, 58 | method: "POST", 59 | body: JSON.stringify({ 60 | address: this.address, 61 | signature: await this.signMessage(signupStartBody.signMessage), 62 | wallet: this.signer.type === "polkadot" ? "polkadot-js" : "metamask", 63 | }), 64 | }); 65 | if (!signupResponse.ok) { 66 | this.log.error(`addressSignupConfirm failed with status code ${signupResponse.status}`); 67 | throw new Error(await signupResponse.text()); 68 | } 69 | const signupBody = (await signupResponse.json()) as { token: string }; 70 | if (!signupBody.token) { 71 | throw new Error("Signup unsuccessful, the authentication token is missing."); 72 | } 73 | this.loggedInData = { token: signupBody.token, network }; 74 | this.log.info("Polkassembly sign up successful."); 75 | } 76 | 77 | public async login(network: string): Promise { 78 | if (this.loggedIn && this.network === network) { 79 | this.log("Already logged in to Polkassembly - login is skipped."); 80 | return; 81 | } 82 | if (this.loggedIn && this.network !== network) { 83 | this.log("Already logged in to Polkassembly but on different network - relogging."); 84 | this.logout(); 85 | } 86 | 87 | this.log("Logging in to Polkassembly..."); 88 | 89 | const loginStartResponse = await fetch(`${this.endpoint}/auth/actions/addressLoginStart`, { 90 | headers: { ...headers, "x-network": network }, 91 | method: "POST", 92 | body: JSON.stringify({ address: this.address }), 93 | }); 94 | if (!loginStartResponse.ok) { 95 | this.log.error(`addressLoginStart failed with status code ${loginStartResponse.status}`); 96 | throw new Error(await loginStartResponse.text()); 97 | } 98 | const loginStartBody = (await loginStartResponse.json()) as { signMessage: string }; 99 | 100 | const loginResponse = await fetch(`${this.endpoint}/auth/actions/addressLogin`, { 101 | headers: { ...headers, "x-network": network }, 102 | method: "POST", 103 | body: JSON.stringify({ 104 | address: this.address, 105 | signature: await this.signMessage(loginStartBody.signMessage), 106 | wallet: this.signer.type === "polkadot" ? "polkadot-js" : "metamask", 107 | }), 108 | }); 109 | if (!loginResponse.ok) { 110 | this.log.error(`addressLogin failed with status code ${loginResponse.status}`); 111 | throw new Error(await loginResponse.text()); 112 | } 113 | const loginBody = (await loginResponse.json()) as { token: string }; 114 | if (!loginBody.token) { 115 | this.log.error("Login to Polkassembly failed, the token was not found in response body"); 116 | this.log.info(`Available response body fields: ${Object.keys(loginBody).join(",")}`); 117 | throw new Error("Login unsuccessful, the authentication token is missing."); 118 | } 119 | this.loggedInData = { token: loginBody.token, network }; 120 | this.log.info("Polkassembly login successful."); 121 | } 122 | 123 | public logout(): void { 124 | this.loggedInData = undefined; 125 | } 126 | 127 | public async loginOrSignup(network: string): Promise { 128 | try { 129 | await this.login(network); 130 | } catch (e) { 131 | if ((e as Error).message.includes("Please sign up")) { 132 | await this.signup(network); 133 | } else { 134 | this.log.error(e, "loginOrSignup to Polkassembly failed."); 135 | throw e; 136 | } 137 | } 138 | } 139 | 140 | public async editPost( 141 | network: string, 142 | opts: { 143 | postId: number; 144 | title: string; 145 | content: string; 146 | proposalType: "referendums_v2"; 147 | }, 148 | ): Promise { 149 | if (!this.token) { 150 | this.log.error("Attempted to edit Polkassembly post without logging in."); 151 | throw new Error("Not logged in."); 152 | } 153 | const body = { 154 | ...opts, 155 | // GENERAL from https://github.com/polkassembly/polkassembly/blob/670f3ab9dae95ccb9a293f8cadfa409620604abf/src/global/post_topics.ts 156 | topicId: 5, 157 | }; 158 | const response = await fetch(`${this.endpoint}/auth/actions/editPost`, { 159 | headers: { ...headers, "x-network": network, authorization: `Bearer ${this.token}` }, 160 | method: "POST", 161 | body: JSON.stringify(body), 162 | }); 163 | if (!response.ok) { 164 | this.log.error(`editPost failed with status code ${response.status}`); 165 | throw new Error(await response.text()); 166 | } 167 | this.log.info("Polkassembly post editing successful."); 168 | } 169 | 170 | async getLastReferendumNumber(network: string, trackNo: number): Promise { 171 | const response = await fetch( 172 | `${this.endpoint}/listing/on-chain-posts?proposalType=referendums_v2&trackNo=${trackNo}&sortBy=newest`, 173 | { headers: { ...headers, "x-network": network }, method: "POST", body: JSON.stringify({}) }, 174 | ); 175 | if (!response.ok) { 176 | this.log.error(`listing/on-chain-posts failed with status code ${response.status}`); 177 | throw new Error(await response.text()); 178 | } 179 | const body = (await response.json()) as { posts: { post_id: number }[] }; 180 | const result = body.posts[0]?.post_id; 181 | this.log.info(`Most recent referendum id on Polkassembly is: ${result}`); 182 | return result; 183 | } 184 | 185 | public async signMessage(message: string): Promise { 186 | const messageInUint8Array: Uint8Array = Buffer.from(message); 187 | if (this.signer.type === "ethereum") { 188 | return await this.signer.wallet.signMessage(message); 189 | } 190 | const signedMessage: Uint8Array = await this.signer.keyringPair.signBytes(messageInUint8Array); 191 | return "0x" + Buffer.from(signedMessage).toString("hex"); 192 | } 193 | } 194 | -------------------------------------------------------------------------------- /src/testUtil.ts: -------------------------------------------------------------------------------- 1 | import crypto from "crypto"; 2 | import { AccountId } from "polkadot-api"; 3 | import { Probot } from "probot"; 4 | 5 | export function randomAddress(): string { 6 | return AccountId().dec(crypto.randomBytes(32)); 7 | } 8 | 9 | // eslint-disable-next-line @typescript-eslint/no-explicit-any,@typescript-eslint/no-unsafe-assignment 10 | export const logMock: Probot["log"] = console.log.bind(console) as any; 11 | logMock.error = console.error.bind(console); 12 | logMock.info = console.log.bind(console); 13 | -------------------------------------------------------------------------------- /src/tip-opengov.e2e.ts: -------------------------------------------------------------------------------- 1 | /* 2 | This is an E2E test for an opengov tip, 3 | from the point of creating a tip, 4 | all the way to completing the referendum. 5 | */ 6 | 7 | import { ConvictionVotingVoteAccountVote, MultiAddress, rococo } from "@polkadot-api/descriptors"; 8 | import { DEV_PHRASE } from "@polkadot-labs/hdkd-helpers"; 9 | import assert from "assert"; 10 | import { createClient, PolkadotClient, PolkadotSigner, TypedApi } from "polkadot-api"; 11 | import { getWsProvider } from "polkadot-api/ws-provider/node"; 12 | import { filter, firstValueFrom, mergeMap, pairwise, race, skip, throwError } from "rxjs"; 13 | 14 | import { generateSigner } from "./bot-initialize"; 15 | import { getWsUrl, rococoConstants } from "./chain-config"; 16 | import { randomAddress } from "./testUtil"; 17 | import { tipUser } from "./tip"; 18 | import { State, TipRequest } from "./types"; 19 | 20 | const logMock: any = console.log.bind(console); // eslint-disable-line @typescript-eslint/no-explicit-any 21 | logMock.error = console.error.bind(console); 22 | 23 | const tipperAccount = "14E5nqKAp3oAJcmzgZhUD2RcptBeUBScxKHgJKU4HPNcKVf3"; // Bob 24 | const treasuryAccount = "13UVJyLnbVp9RBZYFwFGyDvVd1y27Tt8tkntv6Q7JVPhFsTB"; // https://wiki.polkadot.network/docs/learn-account-advanced#system-accounts 25 | 26 | const network = "rococo"; 27 | const wsUrl = getWsUrl(network); 28 | 29 | const expectBalanceIncrease = async (useraddress: string, api: TypedApi, blocksNum: number) => 30 | await firstValueFrom( 31 | race([ 32 | api.query.System.Account.watchValue(useraddress, "best") 33 | .pipe(pairwise()) 34 | .pipe(filter(([oldValue, newValue]) => newValue.data.free > oldValue.data.free)), 35 | api.query.System.Number.watchValue("best").pipe( 36 | skip(blocksNum), 37 | mergeMap(() => 38 | throwError(() => new Error(`Balance of ${useraddress} did not increase in ${blocksNum} blocks`)), 39 | ), 40 | ), 41 | ]), 42 | ); 43 | 44 | describe("E2E opengov tip", () => { 45 | let state: State; 46 | let api: TypedApi; 47 | let alice: PolkadotSigner; 48 | let client: PolkadotClient; 49 | 50 | beforeAll(() => { 51 | const jsonRpcProvider = getWsProvider(wsUrl); 52 | client = createClient(jsonRpcProvider); 53 | api = client.getTypedApi(rococo); 54 | }); 55 | 56 | afterAll(() => { 57 | client.destroy(); 58 | }); 59 | 60 | const getUserBalance = async (userAddress: string): Promise => { 61 | const { data } = await api.query.System.Account.getValue(userAddress, { at: "best" }); 62 | return data.free; 63 | }; 64 | 65 | beforeAll(async () => { 66 | try { 67 | await client.getFinalizedBlock(); 68 | } catch (e) { 69 | console.log( 70 | `For these integrations tests, we're expecting local Rococo on ${wsUrl}. Please refer to the Readme.`, 71 | ); 72 | } 73 | 74 | assert((await getUserBalance(tipperAccount)) >= 0n); 75 | state = { 76 | allowedGitHubOrg: "test", 77 | allowedGitHubTeam: "test", 78 | botTipAccount: generateSigner(`${DEV_PHRASE}//Bob`), 79 | bot: { log: logMock } as any, // eslint-disable-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-explicit-any 80 | }; 81 | alice = generateSigner(`${DEV_PHRASE}//Alice`); 82 | 83 | // In some local dev chains, treasury is broke, so we fund it. 84 | await api.tx.Balances.transfer_keep_alive({ 85 | dest: MultiAddress.Id(treasuryAccount), 86 | value: 10000000000000n, 87 | }).signAndSubmit(alice); 88 | }); 89 | 90 | test("Small OpenGov tip", async () => { 91 | const referendumId = await api.query.Referenda.ReferendumCount.getValue(); // The next free referendum index. 92 | const tipRequest: TipRequest = { 93 | tip: { size: "small" }, 94 | contributor: { githubUsername: "test", account: { address: randomAddress(), network } }, 95 | pullRequestOwner: "test-org", 96 | pullRequestRepo: "test", 97 | pullRequestNumber: 1, 98 | }; 99 | // It is a random new address, so we expect the balance to be zero initially. 100 | expect(await getUserBalance(tipRequest.contributor.account.address)).toEqual(0n); 101 | 102 | // We place the tip proposal. 103 | const result = await tipUser(state, tipRequest); 104 | expect(result.success).toBeTruthy(); 105 | 106 | // Alice votes "aye" on the referendum. 107 | await api.tx.Referenda.place_decision_deposit({ index: referendumId }).signAndSubmit(alice); 108 | 109 | /** 110 | * Weirdly enough, Vote struct is serialized in a special way, where first bit is a "aye" / "nay" bit, 111 | * and the rest is conviction enum 112 | * 113 | * 0b1000_0000 (aye) + 1 (conviction1x) = 129 114 | * 115 | * @see https://github.com/paritytech/polkadot-sdk/blob/efdc1e9b1615c5502ed63ffc9683d99af6397263/substrate/frame/conviction-voting/src/vote.rs#L36-L53 116 | * @see https://github.com/paritytech/polkadot-sdk/blob/efdc1e9b1615c5502ed63ffc9683d99af6397263/substrate/frame/conviction-voting/src/conviction.rs#L66-L95 117 | */ 118 | const vote = 129; 119 | await api.tx.ConvictionVoting.vote({ 120 | poll_index: referendumId, 121 | vote: ConvictionVotingVoteAccountVote.Standard({ balance: 1_000_000n, vote }), 122 | }).signAndSubmit(alice); 123 | 124 | // Waiting for the referendum voting, enactment, and treasury spend period. 125 | await expectBalanceIncrease(tipRequest.contributor.account.address, api, 9); 126 | 127 | // At the end, the balance of the contributor should increase by the KSM small tip amount. 128 | const expectedTip = BigInt(rococoConstants.namedTips.small) * 10n ** BigInt(rococoConstants.decimals); 129 | expect(await getUserBalance(tipRequest.contributor.account.address)).toEqual(expectedTip); 130 | }); 131 | }); 132 | -------------------------------------------------------------------------------- /src/tip-opengov.ts: -------------------------------------------------------------------------------- 1 | import { until } from "@eng-automation/js"; 2 | import { 3 | GovernanceOrigin, 4 | PolkadotRuntimeOriginCaller, 5 | PreimagesBounded, 6 | TraitsScheduleDispatchTime, 7 | WestendRuntimeGovernanceOriginsPalletCustomOriginsOrigin, 8 | WestendRuntimeOriginCaller, 9 | } from "@polkadot-api/descriptors"; 10 | import { ss58Address } from "@polkadot-labs/hdkd-helpers"; 11 | import { getDescriptor } from "#src/chain-config"; 12 | import { Binary, PolkadotClient, Transaction } from "polkadot-api"; 13 | import { Probot } from "probot"; 14 | 15 | import { Polkassembly } from "./polkassembly/polkassembly"; 16 | import { OpenGovTrack, State, TipNetwork, TipRequest, TipResult } from "./types"; 17 | import { encodeProposal, formatReason, tipSizeToOpenGovTrack } from "./util"; 18 | 19 | export async function tipOpenGovReferendumExtrinsic(opts: { client: PolkadotClient; tipRequest: TipRequest }): Promise< 20 | | Exclude 21 | | { 22 | success: true; 23 | referendumExtrinsic: Transaction; 24 | proposalByteSize: number; 25 | encodedProposal: Binary; 26 | track: { track: OpenGovTrack; value: bigint }; 27 | } 28 | > { 29 | const { client, tipRequest } = opts; 30 | const track = tipSizeToOpenGovTrack(tipRequest); 31 | if ("error" in track) { 32 | return { success: false, errorMessage: track.error }; 33 | } 34 | 35 | const encodeProposalResult = await encodeProposal(client, tipRequest); 36 | if ("success" in encodeProposalResult) { 37 | return encodeProposalResult; 38 | } 39 | const { encodedProposal, proposalByteSize } = encodeProposalResult; 40 | 41 | const proposal = PreimagesBounded.Inline(encodedProposal); 42 | 43 | const enactMoment = TraitsScheduleDispatchTime.After(10); 44 | 45 | let referendumExtrinsic: Transaction; 46 | const network: TipNetwork = tipRequest.contributor.account.network; 47 | if (network === "westend" || network === "rococo") { 48 | const api = client.getTypedApi(getDescriptor(network)); 49 | const proposalOrigin = WestendRuntimeOriginCaller.Origins( 50 | track.track.trackName as WestendRuntimeGovernanceOriginsPalletCustomOriginsOrigin, 51 | ); 52 | referendumExtrinsic = api.tx.Referenda.submit({ 53 | proposal, 54 | proposal_origin: proposalOrigin, 55 | enactment_moment: enactMoment, 56 | }); 57 | } else { 58 | const api = client.getTypedApi(getDescriptor(network)); 59 | const proposalOrigin = PolkadotRuntimeOriginCaller.Origins(track.track.trackName as GovernanceOrigin); 60 | referendumExtrinsic = api.tx.Referenda.submit({ 61 | proposal, 62 | proposal_origin: proposalOrigin, 63 | enactment_moment: enactMoment, 64 | }); 65 | } 66 | 67 | return { 68 | success: true, 69 | referendumExtrinsic, 70 | proposalByteSize, 71 | encodedProposal, 72 | track, 73 | }; 74 | } 75 | 76 | export async function tipOpenGov(opts: { 77 | state: State; 78 | client: PolkadotClient; 79 | tipRequest: TipRequest; 80 | }): Promise { 81 | const { 82 | state: { bot, botTipAccount }, 83 | client, 84 | tipRequest, 85 | } = opts; 86 | const { contributor } = tipRequest; 87 | const network = tipRequest.contributor.account.network; 88 | 89 | const preparedExtrinsic = await tipOpenGovReferendumExtrinsic({ client, tipRequest }); 90 | if (!preparedExtrinsic.success) { 91 | return preparedExtrinsic; 92 | } 93 | const { proposalByteSize, referendumExtrinsic, encodedProposal, track } = preparedExtrinsic; 94 | 95 | const address = ss58Address(botTipAccount.publicKey); 96 | const api = client.getTypedApi(getDescriptor(network)); 97 | const nonce = await api.apis.AccountNonceApi.account_nonce(address); 98 | bot.log( 99 | `Tip proposal for ${contributor.account.address}, encoded proposal byte size: ${proposalByteSize}, nonce: ${nonce}`, 100 | ); 101 | 102 | try { 103 | const result = await referendumExtrinsic.signAndSubmit(botTipAccount); 104 | bot.log(`referendum for ${contributor.account.address} included at blockHash ${result.block.hash}`); 105 | 106 | const referendumEvents = api.event.Referenda.Submitted.filter(result.events).filter((event) => { 107 | const proposal = event.proposal.value; 108 | const proposalHex = (proposal instanceof Binary ? proposal : proposal.hash).asHex(); 109 | return proposalHex === encodedProposal.asHex(); 110 | }); 111 | if (referendumEvents.length === 0) { 112 | return { 113 | success: false, 114 | errorMessage: `Transaction ${result.txHash} was submitted, but no "Referenda.Submitted" events were produced`, 115 | }; 116 | } 117 | 118 | return { 119 | success: true, 120 | referendumNumber: referendumEvents[0].index, 121 | blockHash: result.block.hash, 122 | track: track.track, 123 | value: track.value, 124 | }; 125 | } catch (e) { 126 | const msg = `Tip for ${contributor.account.address} referendum status is 👎: ${e}`; 127 | return { success: false, errorMessage: msg }; 128 | } 129 | } 130 | 131 | export const updatePolkassemblyPost = async (opts: { 132 | polkassembly: Polkassembly; 133 | referendumId: number; 134 | tipRequest: TipRequest; 135 | track: OpenGovTrack; 136 | log: Probot["log"]; 137 | }): Promise<{ url: string }> => { 138 | const { polkassembly, referendumId, tipRequest, track, log } = opts; 139 | const condition = async (): Promise => { 140 | const lastReferendum = await polkassembly.getLastReferendumNumber( 141 | tipRequest.contributor.account.network, 142 | track.trackNo, 143 | ); 144 | return lastReferendum !== undefined && lastReferendum >= referendumId; 145 | }; 146 | log.info(`Waiting until referendum ${referendumId.toString()} appears on Polkasssembly`); 147 | await until(condition, 30_000); 148 | polkassembly.logout(); 149 | await polkassembly.loginOrSignup(tipRequest.contributor.account.network); 150 | await polkassembly.editPost(tipRequest.contributor.account.network, { 151 | postId: referendumId, 152 | proposalType: "referendums_v2", 153 | content: formatReason(tipRequest, { markdown: true }), 154 | title: track.trackName.type, 155 | }); 156 | log.info(`Successfully updated Polkasssembly metadata for referendum ${referendumId.toString()}`); 157 | return { 158 | url: `https://${tipRequest.contributor.account.network}.polkassembly.io/referenda/${referendumId.toString()}`, 159 | }; 160 | }; 161 | -------------------------------------------------------------------------------- /src/tip.integration.ts: -------------------------------------------------------------------------------- 1 | /* 2 | These are integration tests that will send out 3 | different sizes of opengov tips. 4 | */ 5 | 6 | import { findFreePorts, until } from "@eng-automation/js"; 7 | import { fixtures, githubWebhooks, mockServer } from "@eng-automation/testing"; 8 | import { rococo, westend } from "@polkadot-api/descriptors"; 9 | import { DEV_PHRASE } from "@polkadot-labs/hdkd-helpers"; 10 | import assert from "assert"; 11 | import fs from "fs/promises"; 12 | import path from "path"; 13 | import { Binary, createClient, PolkadotClient, TypedApi } from "polkadot-api"; 14 | import { getWsProvider } from "polkadot-api/ws-provider/node"; 15 | import { filter, firstValueFrom } from "rxjs"; 16 | import { Readable } from "stream"; 17 | import { GenericContainer, Network, StartedTestContainer, TestContainers, Wait } from "testcontainers"; 18 | 19 | import { randomAddress } from "./testUtil"; 20 | 21 | const tipperAccount = "14E5nqKAp3oAJcmzgZhUD2RcptBeUBScxKHgJKU4HPNcKVf3"; // Bob 22 | 23 | const containterLogsDir = path.join(process.cwd(), "integration_tests", "containter_logs"); 24 | const testCaCertPath = path.join(process.cwd(), "integration_tests", "test-ca.pem"); 25 | const start = Date.now(); 26 | 27 | // Taking all output to integration_tests/containter_logs/*.container.log 28 | // Disabling timestamps for probot logs, which can be read in pretty format using `pino-pretty` 29 | function logConsumer(name: string, addTs: boolean = true): (stream: Readable) => Promise { 30 | return async (stream: Readable) => { 31 | const logsfile = await fs.open(path.join(containterLogsDir, `${name}.log`), "w"); 32 | stream.on("data", (line) => logsfile.write(addTs ? `[${Date.now() - start}ms] ${line}` : line)); 33 | stream.on("err", (line) => logsfile.write(addTs ? `[${Date.now() - start}ms] ${line}` : line)); 34 | stream.on("end", async () => { 35 | await logsfile.write("Stream closed\n"); 36 | await logsfile.close(); 37 | }); 38 | }; 39 | } 40 | 41 | const POLKADOT_VERSION = "v1.15.2"; 42 | const networks = ["rococo", "westend"] as const; 43 | const tipSizes = ["small", "medium", "large", "1", "3"]; 44 | const commonDockerArgs = 45 | "--tmp --alice --execution Native --rpc-port 9945 --rpc-external --no-prometheus --no-telemetry --rpc-cors all --unsafe-force-node-key-generation"; 46 | const probotPort = 3000; // default value; not configured in the app 47 | 48 | export const jsonResponseHeaders = { "content-type": "application/json" }; 49 | 50 | const tipBotOrgToken = "ghs_989898989898989898989898989898dfdfdfd"; 51 | const paritytechStgOrgToken = "ghs_12345678912345678123456723456abababa"; 52 | 53 | describe("tip", () => { 54 | let appContainer: StartedTestContainer; 55 | let rococoContainer: StartedTestContainer; 56 | let rococoClient: PolkadotClient; 57 | let rococoApi: TypedApi; 58 | let westendContainer: StartedTestContainer; 59 | let westendClient: PolkadotClient; 60 | let westendApi: TypedApi; 61 | let gitHub: mockServer.MockServer; 62 | let appPort: number; 63 | 64 | const getUserBalance = async (api: TypedApi, userAddress: string) => { 65 | const { data } = await api.query.System.Account.getValue(userAddress, { at: "best" }); 66 | return data.free; 67 | }; 68 | 69 | const expectTipperMembership = async () => { 70 | await gitHub 71 | .forGet("/orgs/tip-bot-org/teams/tip-bot-approvers/memberships/tipper") 72 | .withHeaders({ Authorization: `token ${tipBotOrgToken}` }) 73 | .thenReply( 74 | 200, 75 | JSON.stringify( 76 | fixtures.github.getOrgMembershipPayload({ 77 | login: "tipper", 78 | org: "tip-bot-approvers", 79 | }), 80 | ), 81 | jsonResponseHeaders, 82 | ); 83 | }; 84 | 85 | const expectNoTipperMembership = async () => { 86 | await gitHub 87 | .forGet("/orgs/tip-bot-org/teams/tip-bot-approvers/memberships/tipper") 88 | .withHeaders({ Authorization: `token ${tipBotOrgToken}` }) 89 | .thenReply( 90 | 404, 91 | JSON.stringify({ 92 | message: "Not Found", 93 | documentation_url: "https://docs.github.com/rest/teams/members#get-team-membership-for-a-user", 94 | status: "404", 95 | }), 96 | jsonResponseHeaders, 97 | ); 98 | }; 99 | 100 | beforeAll(async () => { 101 | await fs.mkdir(containterLogsDir, { recursive: true }); 102 | 103 | const [gitHubPort] = await findFreePorts(1); 104 | 105 | const containerNetwork = await new Network().start(); 106 | 107 | await TestContainers.exposeHostPorts(gitHubPort); 108 | 109 | [rococoContainer, westendContainer, gitHub] = await Promise.all([ 110 | new GenericContainer(`parity/polkadot:${POLKADOT_VERSION}`) 111 | .withWaitStrategy(Wait.forListeningPorts()) 112 | .withCommand(("--chain rococo-dev " + commonDockerArgs).split(" ")) 113 | .withLogConsumer(logConsumer("rococo")) 114 | .withWaitStrategy(Wait.forLogMessage("Concluded mandatory round")) 115 | .withNetwork(containerNetwork) 116 | .withNetworkAliases("localrococo") 117 | .withExposedPorts(9945) 118 | .withPlatform("linux/amd64") 119 | .start(), 120 | new GenericContainer(`parity/polkadot:${POLKADOT_VERSION}`) 121 | .withWaitStrategy(Wait.forListeningPorts()) 122 | .withCommand(("--chain westend-dev " + commonDockerArgs).split(" ")) 123 | .withLogConsumer(logConsumer("westend")) 124 | .withWaitStrategy(Wait.forLogMessage("Concluded mandatory round")) 125 | .withNetwork(containerNetwork) 126 | .withNetworkAliases("localwestend") 127 | .withExposedPorts(9945) 128 | .withPlatform("linux/amd64") 129 | .start(), 130 | mockServer.startMockServer({ name: "GitHub", port: gitHubPort, testCaCertPath }), 131 | ]); 132 | 133 | appContainer = await new GenericContainer(`substrate-tip-bot`) 134 | .withExposedPorts(probotPort) // default port of Probot 135 | .withWaitStrategy(Wait.forListeningPorts()) 136 | .withLogConsumer(logConsumer("application", false)) 137 | .withEnvironment({ 138 | ACCOUNT_SEED: `${DEV_PHRASE}//Bob`, 139 | APP_ID: "123", 140 | // Same private key that's used in command-bot. Not being used anywhere except testing. 141 | PRIVATE_KEY_BASE64: 142 | "LS0tLS1CRUdJTiBSU0EgUFJJVkFURSBLRVktLS0tLQpNSUlFcEFJQkFBS0NBUUVBdGovK1RIV2J4\n" + 143 | "cEdOQ3JxVVBjaUhyQlhkOWM5NGszMjVVU0RFWW4wNzRSYnZpYTM1CklGbGREY0ZmcWFMOTlZeXpI\n" + 144 | "Q0FabFJDalNULzE1c3ZyV1pkVFFvMDM3OXRtWTVwcWUzLzFZSk40eGJhNnR5SEoKUnhQREl6ZGVj\n" + 145 | "emFIYWdjeS95Vm5aeHE4ZHRkanJUa3F2TzJTVXRNdUJLS0tVU3EzZ0YzaFdGQnJremhZcjIragph\n" + 146 | "L3lHTis4aE5mZ3Npb0t2K3pZanA1dkVjMFVwSXQ2eVdtZCtHc2NkMzhDZ3UwR2Qvb292OXBnQVZ0\n" + 147 | "TE5BNForCnlPY1JQdXZ5bzU2Y3oraitmaEpOak5IaXpBL3lNTzk0MDM5U1gxeGNCcjJkNWRHK21q\n" + 148 | "cUk0aHo2bFEwajRnT0EKRExacDVURGNjMHJlSU16ZTF2MFJSU2cyTEt0QlJBekFKUnhXS3dJREFR\n" + 149 | "QUJBb0lCQVFDRW04K25SclFRS2Z3YwpZR0paQ2o1ZDRwTmN0cGVmaWcxN2tJSVV2OWNBRXpZOFVk\n" + 150 | "QkJ6NFE3N0FaMVlsbXpmNnNidmVlZlpUbktwTFdDCk44S0pyK2d2TnA0Szh2TnZhZjRzMnBCcXN5\n" + 151 | "TmZpWFFXcUlqU0pQa0orTkhLdDFTVXU2UkpycWVzaC9HMTcwZGgKMVlUWmIydld4RDVwdFBNNzEv\n" + 152 | "OHBjaVh6b3FDRHYzSldLbnZnMERYQitwemUySjdnVDIrQWFienN4cFN3N0hxTApkMXpmWDF0T2Nx\n" + 153 | "cWV5b25DL1ZRQkIyZXJaNDRlVWdpVDIvSUJpMlJCRU1aaEwwVWlSZkJzaWdxRmczdHFMMHp6ClEy\n" + 154 | "SkdqUFd0YkM1ZDF5cEk0dHRVbDFpZ1JROSsrblI1cE55K3NGZXExTCs2T1VPVWtadVZwNHhES2dI\n" + 155 | "MjlkS0cKdFBCQmg1RVJBb0dCQU8yTForRnVmaU5RTnIzWXJrZTcyQ3BtWUFhdnk0MXhBUjJPazFn\n" + 156 | "TUJDV01sSHhURlIxdgpVaGVPZ01yaGIxanBiTlJhYWwyc3Z4TVg4alRLSlkvcldJWkVLeWROUm9K\n" + 157 | "QXB2eGZ0UXFDTkZRWFNESy9XWWVsCm9mQXpNK2xCQVBid3BaR1RZZmNjSGVRcUtxQURGb0xqbWg1\n" + 158 | "L002YkcwMTBwOU1RaStWVERBVysvQW9HQkFNUm8KMFRiTS9wLzNCTE9WTFUyS2NUQVFFV0pwVzlj\n" + 159 | "bkJjNW1rNzA0cW9SbjhUelJtdnhlVlRXQy9aSGNwNWFYc0s5RApnUFhYenU1bUZPQnhxNXZWNzFa\n" + 160 | "UkJGa2NmOGw2Wmg2ZFVFTHptSmt4Q2NJaEd3M0hidkxtYitSeHQrcFRDbU9NCklyS2lOZWJxS3Zt\n" + 161 | "S3kyUzdPMzJIOThvRUVhRHRNbjMydmowS1RMU1ZBb0dBUDJNRHhWUUd0TVdpMWVZTUczZzAKcHB2\n" + 162 | "SzQvM2xBMGswVXY3SXNxWUNOVUxlSEk3UEE1dkEvQ2c2bGVpeUhiZXNJcjQ5dytGazIyTjRiajND\n" + 163 | "NkRTVQoycjgyQkxiS0tkZTJ0NEdTZmN0Z3kwK3JKRitMTkhjdVR6cGFqOU9Zdmt4WTRnL0NCSDZz\n" + 164 | "TzBaRk9ZMlpaRFAzCjNFdDFMUHZCU3dyM0ZaOS9pTzdBWTJFQ2dZRUFnMnNMQ2RyeVNJQ1ZBY0JB\n" + 165 | "TnRENldVbDNDRjBzMlhJLzNWSWYKYW8zZThvZEdFQWJENkRjS1ZxcldGZUlKdEthOHp4aWcwbDVi\n" + 166 | "RklMelZ4WlgyQWEyaFEvaWsrbVF5M1A5bm1CdQpVczRCZmdjazIyTWhZZi9laWVLTVhkT0ZWdUhI\n" + 167 | "WXNKaWVSbzJiTktrZktKVTQ0cXdESmVNd2Z3ays0T2F0RlFFCkNIMjZ3MTBDZ1lCQVRuekJWeUt0\n" + 168 | "NlgvZHdoNE5OUGxYZjB4TVBrNUpIVStlY290WXNabXNHUmlDV3FhSjFrMm8KVFNQUjE5UHRRVVFh\n" + 169 | "T1FoSWNSc2IvNzlJUjNEUkdYTzVJY0dmblhPVXV0YW14b2xmYjdaODBvL1k5cWo5QUJSQwpKSUQ3\n" + 170 | "Qlc3Q3YvVDI2b1Z5TS9YckVlekNWZDRNYml2SGRyWno2UTRqRWk4VURRL2hNeVhpTmc9PQotLS0t\n" + 171 | "LUVORCBSU0EgUFJJVkFURSBLRVktLS0tLQo=", 172 | APPROVERS_GH_ORG: "tip-bot-org", 173 | APPROVERS_GH_TEAM: "tip-bot-approvers", 174 | WEBHOOK_SECRET: "webhook_secret_value", 175 | GITHUB_BASE_URL: `https://host.testcontainers.internal:${gitHub.port}`, 176 | 177 | NODE_EXTRA_CA_CERTS: "/test-ca.pem", 178 | 179 | INTEGRATION_TEST: "true", 180 | 181 | // node-fetch seems to be ingoring NODE_EXTRA_CA_CERTS 182 | // it's being used internally in @octokit/request, which, in turn, is used in @octokit/core, 183 | // which, in turn, is used in @eng-automation/integration 184 | // @see https://github.com/paritytech/opstooling-integrations/issues/25 185 | NODE_TLS_REJECT_UNAUTHORIZED: "0", 186 | }) 187 | .withWaitStrategy(Wait.forListeningPorts()) 188 | .withNetwork(containerNetwork) 189 | .withCopyFilesToContainer([ 190 | { 191 | source: testCaCertPath, 192 | target: "/test-ca.pem", 193 | }, 194 | ]) 195 | .start(); 196 | 197 | appPort = appContainer.getMappedPort(probotPort); 198 | 199 | rococoClient = createClient(getWsProvider(`ws://localhost:${rococoContainer.getMappedPort(9945)}`)); 200 | rococoApi = rococoClient.getTypedApi(rococo); 201 | 202 | westendClient = createClient(getWsProvider(`ws://localhost:${westendContainer.getMappedPort(9945)}`)); 203 | westendApi = westendClient.getTypedApi(westend); 204 | 205 | // ensure that the connection works 206 | await Promise.all([rococoApi.query.System.Number.getValue(), westendApi.query.System.Number.getValue()]); 207 | 208 | assert(Number(await getUserBalance(rococoApi, tipperAccount)) > 0); 209 | assert(Number(await getUserBalance(westendApi, tipperAccount)) > 0); 210 | 211 | const appInstallations = fixtures.github.getAppInstallationsPayload([ 212 | { 213 | accountLogin: "paritytech-stg", 214 | accountId: 74720417, 215 | id: 155, 216 | }, 217 | { 218 | accountLogin: "tip-bot-org", 219 | accountId: 87878787, 220 | id: 199, 221 | }, 222 | ]); 223 | 224 | await gitHub 225 | .forGet("/repos/paritytech-stg/testre/installation") 226 | .thenReply(200, JSON.stringify(appInstallations[0]), jsonResponseHeaders); 227 | 228 | await gitHub 229 | .forPost("/repos/paritytech-stg/testre/issues/comments/1234532076/reactions") 230 | .withHeaders({ Authorization: `token ${paritytechStgOrgToken}` }) 231 | .thenReply( 232 | 200, 233 | JSON.stringify( 234 | fixtures.github.getIssueCommentReactionPayload({ 235 | content: "eyes", 236 | }), 237 | ), 238 | jsonResponseHeaders, 239 | ); 240 | 241 | await gitHub 242 | .forPost("/app/installations/155/access_tokens") 243 | .thenReply( 244 | 200, 245 | JSON.stringify(fixtures.github.getAppInstallationTokenPayload(paritytechStgOrgToken)), 246 | jsonResponseHeaders, 247 | ); 248 | 249 | await gitHub 250 | .forPost("/app/installations/199/access_tokens") 251 | .thenReply( 252 | 200, 253 | JSON.stringify(fixtures.github.getAppInstallationTokenPayload(tipBotOrgToken)), 254 | jsonResponseHeaders, 255 | ); 256 | 257 | await gitHub 258 | .forGet("/app/installations") 259 | .withQuery({ per_page: "100" }) 260 | .thenReply(200, JSON.stringify(appInstallations), jsonResponseHeaders); 261 | }); 262 | 263 | afterAll(async () => { 264 | rococoClient?.destroy(); 265 | westendClient?.destroy(); 266 | await Promise.all([rococoContainer?.stop(), westendContainer?.stop(), gitHub?.stop(), appContainer?.stop()]); 267 | }); 268 | 269 | describe.each([networks])("%s", (network: "rococo" | "westend") => { 270 | let contributorAddress: string; 271 | beforeEach(async () => { 272 | contributorAddress = randomAddress(); 273 | await gitHub.forGet("/users/contributor").thenReply( 274 | 200, 275 | JSON.stringify( 276 | fixtures.github.getUserPayload({ 277 | login: "contributor", 278 | bio: `${network} address: ${contributorAddress}`, 279 | }), 280 | ), 281 | jsonResponseHeaders, 282 | ); 283 | }); 284 | 285 | test.each(tipSizes)("tips a user (%s)", async (tipSize) => { 286 | await expectTipperMembership(); 287 | 288 | const api = network === "rococo" ? rococoApi : westendApi; 289 | const nextFreeReferendumId = await api.query.Referenda.ReferendumCount.getValue(); 290 | 291 | const successEndpoint = await gitHub 292 | .forPost("/repos/paritytech-stg/testre/issues/4/comments") 293 | .withHeaders({ Authorization: `token ${paritytechStgOrgToken}` }) 294 | .thenReply( 295 | 200, 296 | JSON.stringify( 297 | fixtures.github.getIssueCommentPayload({ 298 | org: "paritytech-stg", 299 | repo: "testre", 300 | comment: { 301 | author: "substrate-tip-bot", 302 | body: "", 303 | id: 4, 304 | }, 305 | }), 306 | ), 307 | ); 308 | 309 | await tipUser(appPort, tipSize); 310 | 311 | await until(async () => !(await successEndpoint.isPending()), 500, 100); 312 | 313 | const [request] = await successEndpoint.getSeenRequests(); 314 | const body = (await request.body.getJson()) as { body: string }; 315 | expect(body.body).toContain(`@tipper A referendum for a ${tipSize}`); 316 | expect(body.body).toContain("was successfully submitted for @contributor"); 317 | expect(body.body).toContain(`Referendum number: **${nextFreeReferendumId}**`); 318 | 319 | // This returns undefined for a bit, so using subscription to wait for the data 320 | const referendum = await firstValueFrom( 321 | api.query.Referenda.ReferendumInfoFor.watchValue(nextFreeReferendumId).pipe( 322 | filter((value) => value !== undefined), 323 | ), 324 | ); 325 | expect(referendum?.type).toEqual("Ongoing"); 326 | }); 327 | 328 | test(`huge tip in ${network}`, async () => { 329 | await expectTipperMembership(); 330 | const successEndpoint = await gitHub 331 | .forPost("/repos/paritytech-stg/testre/issues/4/comments") 332 | .withHeaders({ Authorization: `token ${paritytechStgOrgToken}` }) 333 | .thenReply( 334 | 200, 335 | JSON.stringify( 336 | fixtures.github.getIssueCommentPayload({ 337 | org: "paritytech-stg", 338 | repo: "testre", 339 | comment: { 340 | author: "substrate-tip-bot", 341 | body: "", 342 | id: 4, 343 | }, 344 | }), 345 | ), 346 | ); 347 | 348 | await tipUser(appPort, "1000000"); 349 | 350 | await until(async () => !(await successEndpoint.isPending()), 500, 50); 351 | const [request] = await successEndpoint.getSeenRequests(); 352 | const body = (await request.body.getJson()) as { body: string }; 353 | const currency = network === "rococo" ? "ROC" : "WND"; 354 | expect(body.body).toContain( 355 | `The requested tip value of '1000000 ${currency}' exceeds the BigTipper track maximum`, 356 | ); 357 | }); 358 | 359 | test(`tip link in ${network}`, async () => { 360 | await expectNoTipperMembership(); 361 | const successEndpoint = await gitHub 362 | .forPost("/repos/paritytech-stg/testre/issues/4/comments") 363 | .withHeaders({ Authorization: `token ${paritytechStgOrgToken}` }) 364 | .thenReply( 365 | 200, 366 | JSON.stringify( 367 | fixtures.github.getIssueCommentPayload({ 368 | org: "paritytech-stg", 369 | repo: "testre", 370 | comment: { 371 | author: "substrate-tip-bot", 372 | body: "", 373 | id: 4, 374 | }, 375 | }), 376 | ), 377 | ); 378 | 379 | await tipUser(appPort, "small"); 380 | 381 | await until(async () => !(await successEndpoint.isPending()), 500, 50); 382 | 383 | const [request] = await successEndpoint.getSeenRequests(); 384 | const body = (await request.body.getJson()) as { body: string }; 385 | expect(body.body).toContain( 386 | "Only members of [tip-bot-org/tip-bot-approvers](https://github.com/orgs/tip-bot-org/teams/tip-bot-approvers) have permission to request the creation of the tip referendum from the bot.", 387 | ); 388 | expect(body.body).toContain(`https://polkadot.js.org/apps/?rpc=ws://local${network}:9945#/`); 389 | 390 | const extrinsicHex = body.body.match(/decode\/(\w+)/)?.[1]; 391 | expect(extrinsicHex).toBeDefined(); 392 | 393 | const api = network === "rococo" ? rococoApi : westendApi; 394 | const tx = await api.txFromCallData(Binary.fromHex(extrinsicHex!)); 395 | expect(tx.decodedCall.type).toEqual("Referenda"); 396 | expect(tx.decodedCall.value.type).toEqual("submit"); 397 | }); 398 | }); 399 | }); 400 | 401 | async function tipUser(port: number, tip: string) { 402 | await githubWebhooks.triggerWebhook({ 403 | payload: fixtures.github.getCommentWebhookPayload({ 404 | body: `/tip ${tip}`, 405 | login: "tipper", 406 | issueAuthorLogin: "contributor", 407 | org: "paritytech-stg", 408 | repo: "testre", 409 | }), 410 | githubEventHeader: "issue_comment.created", 411 | port, 412 | }); 413 | } 414 | -------------------------------------------------------------------------------- /src/tip.ts: -------------------------------------------------------------------------------- 1 | import { createClient, PolkadotClient, TypedApi } from "polkadot-api"; 2 | import { getWsProvider } from "polkadot-api/ws-provider/node"; 3 | 4 | import { ChainDescriptor, getDescriptor, getWsUrl } from "./chain-config"; 5 | import { tipOpenGov, tipOpenGovReferendumExtrinsic } from "./tip-opengov"; 6 | import { State, TipNetwork, TipRequest, TipResult } from "./types"; 7 | 8 | export type API = TypedApi>; 9 | 10 | async function createApi( 11 | network: TipNetwork, 12 | state: State, 13 | ): Promise<{ 14 | client: PolkadotClient; 15 | }> { 16 | const { bot } = state; 17 | 18 | const provider = getWsProvider(getWsUrl(network)); 19 | const client = createClient(provider); 20 | 21 | // Check that it works 22 | await client.getFinalizedBlock(); 23 | 24 | // Set up the types 25 | const api = client.getTypedApi(getDescriptor(network)); 26 | 27 | const version = await api.apis.Core.version(); 28 | bot.log(`You are connected to chain ${version.spec_name}#${version.spec_version}`); 29 | 30 | return { client: client }; 31 | } 32 | 33 | /** 34 | * Tips the user using the Bot account. 35 | * The bot will send the referendum creation transaction itself and pay for the fees. 36 | */ 37 | export async function tipUser(state: State, tipRequest: TipRequest): Promise { 38 | const { client } = await createApi(tipRequest.contributor.account.network, state); 39 | 40 | try { 41 | return await tipOpenGov({ state, client, tipRequest }); 42 | } finally { 43 | client.destroy(); 44 | } 45 | } 46 | 47 | /** 48 | * Prepare a referendum extrinsic, but do not actually send it to the chain. 49 | * Create a transaction creation link for the user. 50 | */ 51 | export async function tipUserLink( 52 | state: State, 53 | tipRequest: TipRequest, 54 | ): Promise<{ success: false; errorMessage: string } | { success: true; extrinsicCreationLink: string }> { 55 | const { network } = tipRequest.contributor.account; 56 | const { client } = await createApi(network, state); 57 | 58 | try { 59 | const preparedExtrinsic = await tipOpenGovReferendumExtrinsic({ client, tipRequest }); 60 | if (!preparedExtrinsic.success) { 61 | return preparedExtrinsic; 62 | } 63 | 64 | const transactionHex = (await preparedExtrinsic.referendumExtrinsic.getEncodedData()).asHex(); 65 | 66 | const polkadotAppsUrl = `https://polkadot.js.org/apps/?rpc=${getWsUrl(network)}#/`; 67 | const extrinsicCreationLink = `${polkadotAppsUrl}extrinsics/decode/${transactionHex}`; 68 | return { success: true, extrinsicCreationLink }; 69 | } catch (e) { 70 | return { success: false, errorMessage: e instanceof Error ? e.stack ?? e.message : String(e) }; 71 | } finally { 72 | client.destroy(); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import { GovernanceOrigin } from "@polkadot-api/descriptors"; 2 | import type { MatrixClient } from "matrix-js-sdk"; 3 | import { PolkadotSigner } from "polkadot-api"; 4 | import { Probot } from "probot"; 5 | 6 | import { Polkassembly } from "./polkassembly/polkassembly"; 7 | 8 | export type TipNetwork = "kusama" | "polkadot" | "rococo" | "westend"; 9 | 10 | export type TipSize = "small" | "medium" | "large"; 11 | 12 | // explicitly narrowing values to "SmallTipper" | "BigTipper", in order to get around network differences 13 | export type OpenGovTrack = { trackNo: number; trackName: { type: "SmallTipper" | "BigTipper" } }; 14 | export const SmallTipperTrack: OpenGovTrack = { trackNo: 30, trackName: GovernanceOrigin.SmallTipper() }; 15 | export const BigTipperTrack: OpenGovTrack = { trackNo: 31, trackName: GovernanceOrigin.BigTipper() }; 16 | 17 | export type ChainConfig = { 18 | decimals: bigint; 19 | currencySymbol: string; 20 | smallTipperMaximum: number; 21 | bigTipperMaximum: number; 22 | namedTips: Record; 23 | }; 24 | 25 | export type ContributorAccount = { 26 | address: string; 27 | network: TipNetwork; 28 | }; 29 | 30 | export type Contributor = { 31 | githubUsername: string; 32 | account: ContributorAccount; 33 | }; 34 | 35 | export type State = { 36 | allowedGitHubOrg: string; 37 | allowedGitHubTeam: string; 38 | botTipAccount: PolkadotSigner; 39 | bot: Probot; 40 | polkassembly?: Polkassembly | undefined; 41 | matrix?: 42 | | { 43 | client: MatrixClient; 44 | roomId: string; 45 | } 46 | | undefined; 47 | }; 48 | 49 | export type TipRequest = { 50 | contributor: Contributor; 51 | pullRequestNumber: number; 52 | pullRequestRepo: string; 53 | pullRequestOwner: string; 54 | tip: { 55 | size: TipSize | bigint; 56 | }; 57 | }; 58 | 59 | export type TipResult = 60 | | { 61 | success: true; 62 | referendumNumber: number | null; 63 | blockHash: string; 64 | track: OpenGovTrack; 65 | value: bigint; 66 | } 67 | | { success: false; errorMessage: string }; 68 | 69 | // https://docs.github.com/en/rest/reactions/reactions#about-reactions 70 | export type GithubReactionType = "+1" | "-1" | "laugh" | "confused" | "heart" | "hooray" | "rocket" | "eyes"; 71 | -------------------------------------------------------------------------------- /src/util.test.ts: -------------------------------------------------------------------------------- 1 | import { randomAddress } from "./testUtil"; 2 | import { parseContributorAccount } from "./util"; 3 | 4 | describe("Utility functions", () => { 5 | describe("parseContributorAccount", () => { 6 | test("Can parse the account", () => { 7 | const address = randomAddress(); 8 | const result = parseContributorAccount([`kusama address: ${address}`]); 9 | if ("error" in result) { 10 | throw new Error(result.error); 11 | } 12 | expect(result.network).toEqual("kusama"); 13 | expect(result.address).toEqual(address); 14 | }); 15 | 16 | test("allows uppercase", () => { 17 | const address = randomAddress(); 18 | const result = parseContributorAccount([`Kusama Address: ${address}`]); 19 | if ("error" in result) { 20 | throw new Error(result.error); 21 | } 22 | expect(result.network).toEqual("kusama"); 23 | expect(result.address).toEqual(address); 24 | }); 25 | 26 | test("allows whitespace", () => { 27 | const address = randomAddress(); 28 | const result = parseContributorAccount([`\nkusama Address: ${address}\n\n`]); 29 | if ("error" in result) { 30 | throw new Error(result.error); 31 | } 32 | expect(result.network).toEqual("kusama"); 33 | expect(result.address).toEqual(address); 34 | }); 35 | 36 | // Some people naturally put backticks aronud the address. 37 | test("allows backticks around the address", () => { 38 | const address = randomAddress(); 39 | const result = parseContributorAccount([`Kusama Address: \`${address}\``]); 40 | if ("error" in result) { 41 | throw new Error(result.error); 42 | } 43 | expect(result.network).toEqual("kusama"); 44 | expect(result.address).toEqual(address); 45 | }); 46 | 47 | test("Returns error message when cannot parse", () => { 48 | const address = randomAddress(); 49 | const result = parseContributorAccount([`kusama: ${address}`]); 50 | if (!("error" in result)) { 51 | throw new Error("Expected error message not found."); 52 | } 53 | expect(result.error).toMatch( 54 | "Hey 👋, thanks for your contribution. We offer to propose a tip for you to OpenGov", 55 | ); 56 | }); 57 | 58 | test("Throws on invalid network", () => { 59 | const address = randomAddress(); 60 | const result = parseContributorAccount([`kussama address: ${address}`]); 61 | if (!("error" in result)) { 62 | throw new Error("Expected error message not found."); 63 | } 64 | expect(result.error).toMatch("Invalid network"); 65 | }); 66 | 67 | test("First body takes precedence over following bodies", () => { 68 | const addressA = randomAddress(); 69 | const addressB = randomAddress(); 70 | const result = parseContributorAccount([`kusama address: ${addressA}`, `polkadot address: ${addressB}`]); 71 | if ("error" in result) { 72 | throw new Error(result.error); 73 | } 74 | expect(result.network).toEqual("kusama"); 75 | expect(result.address).toEqual(addressA); 76 | }); 77 | 78 | test("Takes second body if the first one cannot be parsed", () => { 79 | const addressA = randomAddress(); 80 | const addressB = randomAddress(); 81 | 82 | { 83 | const result = parseContributorAccount([`kusama: ${addressA}`, `polkadot address: ${addressB}`]); 84 | if ("error" in result) { 85 | throw new Error(result.error); 86 | } 87 | expect(result.network).toEqual("polkadot"); 88 | expect(result.address).toEqual(addressB); 89 | } 90 | 91 | { 92 | const result = parseContributorAccount([null, `polkadot address: ${addressB}`]); 93 | if ("error" in result) { 94 | throw new Error(result.error); 95 | } 96 | expect(result.network).toEqual("polkadot"); 97 | expect(result.address).toEqual(addressB); 98 | } 99 | }); 100 | 101 | test("Throws when the first body has invalid network", () => { 102 | const addressA = randomAddress(); 103 | const addressB = randomAddress(); 104 | const result = parseContributorAccount([`kussama address: ${addressA}`, `polkadot address: ${addressB}`]); 105 | if (!("error" in result)) { 106 | throw new Error("Expected error message not found."); 107 | } 108 | expect(result.error).toMatch("Invalid network"); 109 | }); 110 | }); 111 | }); 112 | -------------------------------------------------------------------------------- /src/util.ts: -------------------------------------------------------------------------------- 1 | import { MultiAddress } from "@polkadot-api/descriptors"; 2 | import assert from "assert"; 3 | import { Binary, PolkadotClient } from "polkadot-api"; 4 | 5 | import { getChainConfig, getDescriptor } from "./chain-config"; 6 | import { 7 | BigTipperTrack, 8 | ContributorAccount, 9 | OpenGovTrack, 10 | SmallTipperTrack, 11 | TipNetwork, 12 | TipRequest, 13 | TipResult, 14 | TipSize, 15 | } from "./types"; 16 | 17 | const validTipSizes: { [key: string]: TipSize } = { small: "small", medium: "medium", large: "large" } as const; 18 | const validNetworks: { [key: string]: TipNetwork } = { 19 | polkadot: "polkadot", 20 | kusama: "kusama", 21 | rococo: "rococo", 22 | westend: "westend", 23 | } as const; 24 | 25 | export function getTipSize(tipSizeInput: string | undefined): TipSize | bigint | { error: string } { 26 | if (tipSizeInput === undefined || tipSizeInput.length === 0) { 27 | return { error: "Tip size not specified" }; 28 | } 29 | 30 | try { 31 | // See if the input specifies an explicit numeric tip value. 32 | return BigInt(tipSizeInput); 33 | } catch {} 34 | 35 | if (!tipSizeInput || !(tipSizeInput in validTipSizes)) { 36 | return { error: `Invalid tip size. Please specify one of ${Object.keys(validTipSizes).join(", ")}.` }; 37 | } 38 | 39 | return validTipSizes[tipSizeInput]; 40 | } 41 | 42 | export function tipSizeToOpenGovTrack(tipRequest: TipRequest): 43 | | { track: OpenGovTrack; value: bigint } 44 | | { 45 | error: string; 46 | } { 47 | const chainConfig = getChainConfig(tipRequest.contributor.account.network); 48 | const decimalPower = 10n ** chainConfig.decimals; 49 | const tipSize = tipRequest.tip.size; 50 | const tipValue = typeof tipSize === "bigint" ? tipSize : chainConfig.namedTips[tipSize]; 51 | const tipValueWithDecimals = tipValue * decimalPower; 52 | if (tipValue <= chainConfig.smallTipperMaximum) { 53 | return { track: SmallTipperTrack, value: tipValueWithDecimals }; 54 | } 55 | if (tipValue <= chainConfig.bigTipperMaximum) { 56 | return { track: BigTipperTrack, value: tipValueWithDecimals }; 57 | } 58 | return { 59 | error: `The requested tip value of '${formatTipSize(tipRequest)}' exceeds the BigTipper track maximum of '${ 60 | chainConfig.bigTipperMaximum 61 | } ${chainConfig.currencySymbol}'.`, 62 | }; 63 | } 64 | 65 | export function parseContributorAccount(bodys: (string | null)[]): ContributorAccount | { error: string } { 66 | for (const body of bodys) { 67 | const matches = 68 | typeof body === "string" && 69 | body.match( 70 | // match "polkadot address:
" 71 | /(\S+)\s*address:\s*`?([a-z0-9]+)`?/i, 72 | ); 73 | 74 | if (matches === false || matches === null || matches.length != 3) { 75 | continue; 76 | } 77 | 78 | const [matched, networkInput, address] = matches; 79 | assert(networkInput, `networkInput could not be parsed from "${matched}"`); 80 | assert(address, `address could not be parsed from "${matched}"`); 81 | 82 | const network = 83 | networkInput.toLowerCase() in validNetworks 84 | ? validNetworks[networkInput.toLowerCase() as keyof typeof validNetworks] 85 | : undefined; 86 | if (!network) { 87 | return { 88 | error: `Invalid network: "${networkInput}". Please select one of: ${Object.keys(validNetworks).join(", ")}.`, 89 | }; 90 | } 91 | 92 | return { network, address }; 93 | } 94 | 95 | return { 96 | error: `Hey 👋, thanks for your contribution. We offer to propose a tip for you to OpenGov 🤩 97 | 98 | You can pick between DOT or KSM. Please put either your Polkadot or Kusama address into the Pull Request description or your GitHub bio. 99 | The format should be like this: \`{network} address: {address}\` 100 | Just replace \`{network}\` with either ${Object.keys(validNetworks).join(", ")} and \`{address}\` with your actual address. 101 | 102 | You still need to claim the tip after it was approved by OpenGov; 103 | please check the [Contributing docs](https://github.com/paritytech/polkadot-sdk/blob/master/docs/contributor/CONTRIBUTING.md#Tip) for help.`, 104 | }; 105 | } 106 | 107 | /** 108 | * Formats the tip request into a human-readable string. 109 | * For example: "TO: someone FOR: substrate#123 (13 KSM)" 110 | * 111 | * With markdown option enabled, it will produce a multi-line markdown text. 112 | */ 113 | export const formatReason = (tipRequest: TipRequest, opts: { markdown: boolean } = { markdown: false }): string => { 114 | const { contributor, pullRequestNumber, pullRequestOwner, pullRequestRepo } = tipRequest; 115 | if (!opts.markdown) { 116 | return `TO: ${contributor.githubUsername} FOR: ${pullRequestRepo}#${pullRequestNumber} (${formatTipSize( 117 | tipRequest, 118 | )})`; 119 | } 120 | 121 | return `This is a tip created by the [tip-bot](https://github.com/paritytech/substrate-tip-bot/). 122 | 123 | ### Details 124 | 125 | - **Repository:** [${pullRequestRepo}](https://github.com/${pullRequestOwner}/${pullRequestRepo}) 126 | - **PR:** [#${pullRequestNumber}](https://github.com/${pullRequestOwner}/${pullRequestRepo}/pull/${pullRequestNumber}) 127 | - **Contributor:** [${contributor.githubUsername}](https://github.com/${contributor.githubUsername}) 128 | - **Tip Size:** ${formatTipSize(tipRequest)} 129 | `; 130 | }; 131 | 132 | /** 133 | * @returns For example "medium (5 KSM)" or "13 KSM". 134 | */ 135 | export const formatTipSize = (tipRequest: TipRequest): string => { 136 | const tipSize = tipRequest.tip.size; 137 | const chainConfig = getChainConfig(tipRequest.contributor.account.network); 138 | if (typeof tipSize === "bigint") { 139 | // e.g. "13 KSM" 140 | return `${tipSize.toString()} ${chainConfig.currencySymbol}`; 141 | } 142 | const value = chainConfig.namedTips[tipSize]; 143 | // e.g. "medium (5 KSM) 144 | return `${tipSize} (${value.toString()} ${chainConfig.currencySymbol})`; 145 | }; 146 | 147 | /** 148 | * Matrix handles of the team supporting this project. 149 | * Currently - Engineering Automation / Opstooling. 150 | * It is used to tag these usernames when there is a failure. 151 | */ 152 | export const teamMatrixHandles = 153 | process.env.NODE_ENV === "development" ? [] : ["@przemek", "@mak", "@yuri", "@bullrich"]; // Don't interrupt other people when testing. 154 | 155 | export const byteSize = (extrinsic: Uint8Array): number => extrinsic.length * Uint8Array.BYTES_PER_ELEMENT; 156 | 157 | export const encodeProposal = async ( 158 | client: PolkadotClient, 159 | tipRequest: TipRequest, 160 | ): Promise<{ encodedProposal: Binary; proposalByteSize: number } | Exclude> => { 161 | const track = tipSizeToOpenGovTrack(tipRequest); 162 | if ("error" in track) { 163 | return { success: false, errorMessage: track.error }; 164 | } 165 | const contributorAddress = tipRequest.contributor.account.address; 166 | 167 | const beneficiary = MultiAddress.Id(contributorAddress); 168 | 169 | const api = client.getTypedApi(getDescriptor(tipRequest.contributor.account.network)); 170 | const proposalTx = api.tx.Treasury.spend_local({ amount: track.value, beneficiary }); 171 | 172 | const encodedProposal = await proposalTx.getEncodedData(); 173 | const proposalByteSize = byteSize(encodedProposal.asBytes()); 174 | if (proposalByteSize >= 128) { 175 | return { 176 | success: false, 177 | errorMessage: `The proposal length of ${proposalByteSize} equals or exceeds 128 bytes and cannot be inlined in the referendum.`, 178 | }; 179 | } 180 | return { encodedProposal, proposalByteSize }; 181 | }; 182 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowJs": true, 4 | "allowSyntheticDefaultImports": true, 5 | "baseUrl": ".", 6 | "declaration": false, 7 | "esModuleInterop": true, 8 | "lib": ["ES2022"], 9 | "module": "commonjs", 10 | "moduleResolution": "node", 11 | "noEmitOnError": true, 12 | "noFallthroughCasesInSwitch": true, 13 | "noImplicitAny": true, 14 | "outDir": "./dist", 15 | "paths": { 16 | "#src/*": ["./src/*"] 17 | }, 18 | "resolveJsonModule": true, 19 | "rootDir": ".", 20 | "skipLibCheck": true, 21 | "sourceMap": true, 22 | "strict": true, 23 | "target": "ES2022", 24 | "typeRoots": ["./node_modules/@types", "./src"], 25 | "types": ["node", "jest"], 26 | "useUnknownInCatchVariables": false 27 | }, 28 | "include": ["src/**/*", "package.json"], 29 | "exclude": ["node_modules/**/*"] 30 | } 31 | --------------------------------------------------------------------------------