Skip to content

Proposal for token reward #826

Description

@dadiorchen

Proposal: Token reward for volunteers on merged PRs

Problem

Reward a Greenstand volunteer with tokens when their contribution lands.

  • Trigger: a PR is merged and closes an issue.
  • Amount: carried on the issue as label token:[number].
  • Recipient: the PR author (volunteer).
  • Delivery: the tokens land in the volunteer's Greenstand wallet.

Key finding: the plumbing already exists

Nothing here needs a new payment system. The existing register-reward flow already gifts tokens from a treasury wallet via a Keycloak service account (apps/user/.../queue-listener.service.tsPOST /transfers). We reuse that pattern.

  • Treasury: existing SENDER_WALLET_ID wallet, Greenstand-controlled, pre-funded.
  • Identity: the dev treetracker realm already has GitHub as a login provider. Signing in with GitHub links a volunteer's GitHub identity to their Greenstand account automatically. No mapping table, no Keycloak config.
  • Transfer: POST /transfers (bundle) from treasury to the resolved wallet.

Recommendations

Topic Recommendation
Amount source token:[number] label, hardened
Trigger GitHub Action on PR merge
Treasury SENDER_WALLET_ID, Greenstand-controlled, pre-funded
Identity Existing GitHub Keycloak IdP (already live)
Delivery Direct pending transfer to volunteer's wallet (accept in-app); claim link = fallback

Flow

flowchart TD
    A["PR merged"] --> B["GitHub Action"]
    B --> C["Resolve closed issues (GraphQL closingIssuesReferences)"]
    C --> D["For each issue with token:N label"]
    D --> E["Resolve identity: PR author login → GitHub numeric id → Keycloak user (Admin API: idpAlias=github, idpUserId)"]
    E --> F{"Account GitHub-linked?"}
    F -->|Yes| G["POST /transfers bundle N: treasury → volunteer wallet (pending, accept in-app)"]
    F -->|No| H["PR comment: link your GitHub account, then re-run"]
    G --> I["PR comment: Reward sent, open the wallet app to accept"]
    I --> J["Ledger entry (idempotency + audit)"]
Loading

Components

  1. GitHub Action: .github/workflows/token-reward.yml; runs on pull_request closed where merged == true, base main.
  2. Issue/amount resolver: GraphQL closingIssuesReferences on the PR (authoritative; covers body keywords and manually-linked issues); parse token:N label per issue.
  3. Identity resolver: GitHub login → numeric id (GitHub API) → Keycloak user (Admin API: search by idpAlias=github, idpUserId) → wallet.keycloak_account_idwallet_id.
  4. Reward action: treasury service account mints a Keycloak client-credentials token, calls POST /transfers { sender_wallet: SENDER_WALLET_ID, receiver_wallet: <wallet_id>, bundle: { bundle_size: N } }.
  5. Notifier: posts the outcome as a PR comment (sent / pending-enrollment / skipped).
  6. Ledger: records rewarded issues for idempotency + audit.

Identity & eligibility

  • Resolution uses the stable GitHub numeric id, not the username, so it survives username changes.
  • Eligibility rule: the volunteer's Greenstand account must be GitHub-linked (sign in with GitHub once). Accounts registered by password or Google alone won't resolve until they link GitHub.
  • Enrollment is self-service and already possible today, no new UI.

Delivery

  • Primary: direct POST /transfers bundle → lands as a pending transfer in the volunteer's in-app notifications; they accept. Fully private (no public link, no email).
  • Fallback (later phase): for a not-yet-linked author, a bound action-token claim link emailed privately. Requires a small redeem() change to bind the token to the recipient (today it is bearer).

token:[number] label handling

  • Exactly one token:N per issue; N a positive integer.
  • Missing/invalid → no reward, warning comment (manual re-trigger path).
  • Label read at merge time is authoritative.

Idempotency & security

  • Idempotent: reward once per issue (key = issue number; mark paid via ledger / token:paid label). Re-runs, edits, reopen/reclose never double-pay.
  • Trust boundary: reward only on merge into protected main in the base repo, never from fork-run workflows; treasury/service-account secrets never exposed to fork PRs.
  • Server-side cap: max N per issue enforced on the transfer path, so a mislabeled token:9999 can't drain the treasury even if the workflow is bypassed.
  • Auditable: every reward logged (issue, PR, author, amount, transfer id, timestamp).

Edge cases

  • Author not GitHub-linked → PR comment to enroll; re-run after linking.
  • Google/password-only account → same as above (link GitHub).
  • Bot / maintainer PRs → denylist (pull_request.user.type == "Bot"; optional maintainer exclusion).
  • PR closes multiple token-labeled issues → reward per issue.
  • Multiple PRs on one issue → issue closed once → rewarded once (idempotency).

Prerequisites (the only new infra)

  1. A Keycloak service account (client-credentials) with:
    • realm-management permission to look up users by GitHub identity, and
    • control of the treasury (SENDER_WALLET_ID) wallet so its transfers complete.
  2. Treasury wallet pre-funded with a batch of reward tokens (ops; tokens are capture-bound, not minted on demand).
  3. Server-side reward cap value.

Phased rollout

  • Phase 1 (MVP): Action → resolve issue + token:N → resolve GitHub-linked wallet → treasury POST /transfers → PR comment + ledger. Not-linked authors get an enroll comment.
  • Phase 2: bound claim-link email fallback for unlinked authors (adds the redeem() binding fix).
  • Phase 3: admin dashboard, richer audit, policy config (caps, allow/deny), analytics.

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions