| name | distribute-tokens |
| description | Two-phase contributor rewards - plan builds a tier-priced payout from the repo's merged-PR ranking; send executes it on-chain via Bankr Wallet API with per-recipient idempotency and dry-run. |
| metadata | {"title":"Distribute Tokens","category":"crypto","var":"","tags":["community","crypto"],"requires":["BANKR_API_KEY?"],"capabilities":["external_api","writes_external_host","onchain_writes","sends_notifications"]} |
${var} — Phase + target selector. Grammar: [plan:|all:][dry-run:]<target>
- `` (empty) /
<label> / dry-run:<label> → send phase: distribute a list from memory/distributions.yml (empty = first list). [default — no prefix]
plan: / plan:<week> / plan:dry-run / plan:dry-run:<week> → plan phase only: compute rewards from the repo's merged PRs and write the list into memory/distributions.yml.
all: / all:<week> / all:dry-run / all:dry-run:<week> → plan then send in one run.
<label> is a distribution-list label (e.g. contributors-2026-W17). <week> is an ISO week (2026-W17); empty <week> = most recent completed ISO week. dry-run: previews without side effects (no yml/state writes in plan; no transfers in send).
Why this design
This skill owns the whole contributor flywheel: who deserves what (plan) and moving the money (send). It is split into two phases that can run independently or chained.
Plan phase — the wiring the project was missing. Merged PRs already name the people moving the project, but shipped work had no path to a wallet credit. The plan phase is that wiring: it ranks contributors by the PRs they merged in the target week (straight from the GitHub API), prices each eligible contributor against a tier table, and writes a labelled list into memory/distributions.yml — the exact file the send phase reads. Keeping a human-visible diff on memory/distributions.yml between plan and execution is the cheapest possible audit trail when real money is involved: the plan lands in git, and the operator (or all: mode, or a chained step) runs the send next.
Send phase — this moves real money. The biggest failure mode is double-sending (re-runs, retries after partial failures, day-rollover bypass of "skip if today" logic) or sending into a black hole (no preflight balance, deprecated API path, missing handle resolution). The send phase therefore:
- Persists per-recipient idempotency state in
memory/state/distributions.json keyed on (list, recipient, date_utc) with the txHash. A successful transfer is never re-sent within the same UTC day, even across re-runs or workflow restarts.
- Two-phase execution: RESOLVE (validate config, key, balance, resolve all handles → addresses, build plan) → EXECUTE (send each transfer, persist state after each one). RESOLVE failures abort before any send.
- Dry-run mode outputs the full plan with no transfers.
- Wallet API only for actual transfers — Bankr's docs deprecate the Agent API for transfers. Agent API is used only for handle→address resolution.
The two phases stay decoupled by design: the send phase is the only sanctioned transfer path and owns the idempotency state file, so the plan phase never touches transfer state. all: mode simply runs plan then send in sequence (plan writes the list, send reads it), so nothing is re-implemented.
Config
Reads two independent config/state surfaces depending on phase:
memory/distributions.yml — the distribution lists (read by send, written by plan).
- The plan phase computes its ranking live from the repo's merged PRs via the GitHub API — no input file (see Phase A).
memory/state/distributions.json — per-recipient send idempotency (read/written by send).
memory/state/contributor-reward-state.json — plan idempotency + first-PR-bonus history (read/written by plan).
If memory/distributions.yml is missing when the send phase needs it, bootstrap with a commented template (see Send Step 1) and exit cleanly with DISTRIBUTE_TOKENS_OK — bootstrapped distributions.yml; edit and re-run.
defaults:
token: USDC
amount: "5"
chain: base
lists:
contributors:
description: "Weekly contributor rewards"
token: USDC
amount: "10"
recipients:
- handle: "@alice_dev"
amount: "15"
- handle: "@bob_builder"
- address: "0x742d...5678"
label: "Charlie"
amount: "20"
Required secrets
| Secret | Phase | Purpose |
|---|
BANKR_API_KEY | send (and any dry-run send, which still preflights) | Bankr API key (bk_...). Must be read-write with Wallet API enabled. Read-only keys → 403. Not needed for plan: (pure local file I/O). |
Token addresses on Base
- USDC:
0x833589fcd6edb6e08f4c7c32d4f71b54bda02913
- ETH (native):
tokenAddress: "0x0000000000000000000000000000000000000000", isNativeToken: true
Tier pricing (plan phase)
| Rank in leaderboard | Reward (USDC) |
|---|
| 1 | 25 |
| 2 | 15 |
| 3 | 10 |
| 4 | 5 |
| 5 | 5 |
First-PR bonus: +5 USDC, additive, applied once-ever per login (tracked in memory/state/contributor-reward-state.json). Rewards landing your first merged upstream PR — the highest-leverage signal in the leaderboard scoring.
Eligibility floor: score ≥ 10 AND the contributor must own a non-empty @handle (logins without @ prefix in the table are skipped — bots and parsing artifacts). A single merged upstream PR (+10) qualifies — the goal is to reward shipped work, not gate on volume.
Default token: USDC on Base. Operator can override per-recipient amounts in memory/distributions.yml after the plan is written if a special bonus is warranted.
Read memory/MEMORY.md and scan the last ~3 days of memory/logs/ for anything already reported (don't re-report the same signal).
Step 0 — Parse the selector and dispatch
Resolve time anchors up front: today=$(date -u +%F) and today_utc="$today".
Parse ${var}:
- Phase prefix. If
${var} starts with plan: → PHASE=plan, strip plan:. Else if it starts with all: → PHASE=all, strip all:. Else → PHASE=send and do not strip anything (the remaining legacy grammar is parsed by the send phase itself).
- Dry-run. For
PHASE=plan/all: if the remainder starts with dry-run (optionally dry-run:), set MODE=dry-run and strip that token; else MODE=execute. (For PHASE=send, the send phase parses dry-run: itself — see Send Step 1.)
- Target.
PHASE=send: the (unstripped) var is the send target — dry-run:<label> or <label> or empty.
PHASE=plan/all: the remainder is an optional <week>. If it matches ^\d{4}-W\d{2}$, set TARGET_WEEK=<week>; else compute TARGET_WEEK=$(date -u +%G-W%V) (ISO-8601 week-numbering year + week — %G/%V not %Y/%U, so Monday-anchored weeks roll over correctly across years).
Dispatch:
PHASE=plan → run Phase A only.
PHASE=send → run Phase B only.
PHASE=all → run Phase C (A then B).
Selector examples: `` → send first list · contributors-2026-W17 → send that list · dry-run:contributors-2026-W17 → dry-run send · plan: → plan most recent leaderboard · plan:2026-W17 → plan that week · plan:dry-run → plan preview · all: → plan + send most recent · all:dry-run:2026-W17 → full end-to-end preview for that week.
Phase A — Plan (reward computation)
Ranks the target week's merged-PR authors (GitHub API) and turns that ranking into a tier-priced list in memory/distributions.yml.
A1. Determine the target week and repo
REPO="${GITHUB_REPOSITORY:-$(git config --get remote.origin.url | sed -E 's#.*[:/]([^/]+/[^/]+?)(\.git)?$#\1#')}" — the running instance's repo.
TARGET_WEEK comes from the selector; empty = the most recent completed ISO week (the last full Mon–Sun). Compute its UTC bounds WEEK_START..WEEK_END as ISO datetimes (YYYY-MM-DDT00:00:00Z).
A2. Rank contributors by merged PRs in the week
Compute the ranking directly from GitHub — no upstream skill or article required.
- Fetch every PR merged inside the window, by author:
gh api -X GET search/issues -f q="repo:${REPO} is:pr is:merged merged:${WEEK_START}..${WEEK_END}" --paginate --jq '.items[].user.login'
- Drop bot authors (
*[bot], dependabot*, github-actions*). Count each remaining login's merged PRs → score. Rank by score descending; tie-break by earliest merge time, then login ascending.
- First-PR ✨ per ranked login — did they have any prior merged PR to the repo?
gh api -X GET search/issues -f q="repo:${REPO} is:pr is:merged author:${login} merged:<${WEEK_START}" --jq '.total_count' → 0 means this is their first-ever merged PR (set first_pr_marker = ✨).
- If zero merged PRs in the window → log
CONTRIBUTOR_REWARD_NO_MERGED_PRS — week ${TARGET_WEEK} to memory/logs/${today}.md, exit silently (no notify). Nothing shipped, nothing to reward.
- If the GitHub API is unreachable (see Network note for the
gh api → WebFetch fallback) → log CONTRIBUTOR_REWARD_API_FAIL, notify the operator, exit.
A3. Load plan idempotency state
{
"weeks": {
"2026-W17": {
"written_at": "2026-04-26T09:00:00Z",
"label": "contributors-2026-W17",
"source": "github:merged-prs",
"rewards": [
{ "login": "alice_dev", "rank": 1, "score": 47, "amount": "25", "first_pr_bonus": false },
{ "login": "bob_builder", "rank": 2, "score": 31, "amount": "20", "first_pr_bonus": true }
]
}
},
"first_pr_bonus_paid": ["bob_builder", "carol_eng"]
}
Bootstrap with {"weeks": {}, "first_pr_bonus_paid": []} if the file doesn't exist.
A4. Compute the plan
For each ranked login with rank ≤ 5 AND score ≥ 1 (at least one merged PR):
- Look up
base_amount from the tier table (rank 1→25, 2→15, 3→10, 4-5→5).
- If
first_pr_marker == "✨" AND login ∉ first_pr_bonus_paid → set first_pr_bonus = true, amount = base_amount + 5. Otherwise first_pr_bonus = false, amount = base_amount.
- Build row:
{ rank, login, score, base_amount, first_pr_bonus, amount }.
If weeks[TARGET_WEEK] already exists in state → this week was already processed. Diff the current plan against state.weeks[TARGET_WEEK].rewards keyed on login:
- If diffs are empty (same logins, same amounts) → log
CONTRIBUTOR_REWARD_ALREADY_PROCESSED — week ${TARGET_WEEK}, exit silently (no notify). Idempotent re-run.
- If diffs exist (leaderboard re-ran after first reward write — late tweet bumped a score, etc.) → flag
RE_PROCESS. Continue but don't re-pay anyone already in state.weeks[TARGET_WEEK].rewards; add only the deltas. New entries get full reward; existing entries with bumped amounts get the delta (e.g. moved from rank 3→2 = additional 5 USDC top-up). Demoted entries are not clawed back.
If the plan is empty (zero eligible contributors after threshold + dedup) → log CONTRIBUTOR_REWARD_NO_ELIGIBLE and exit silently.
A5. Render the plan
Contributor Reward Plan — ${TARGET_WEEK} (${MODE})
Source: ${LEADERBOARD_FILE}
Tier: rank 1=25, 2=15, 3=10, 4-5=5 USDC; first-PR bonus +5 once per login.
✓ #1 @alice_dev score 47 → 25 USDC [NEW]
✓ #2 @bob_builder score 31 → 20 USDC (15 + 5 first-PR)[NEW + BONUS]
✓ #3 @carol_eng score 24 → 10 USDC [NEW]
✓ #4 @dave_ops score 18 → 5 USDC [NEW]
↻ #5 @eve_hax score 14 → 5 USDC [DEDUP — already in state]
Total to write: 60 USDC across 4 new entries.
Total in state for ${TARGET_WEEK} after write: 5 entries, 65 USDC.
Next: distribute-tokens "dry-run:contributors-${TARGET_WEEK}" (preview)
distribute-tokens "contributors-${TARGET_WEEK}" (execute)
If MODE=dry-run (plan-only dry-run, i.e. plan:dry-run...): notify this plan with header *Contributor Reward Plan — ${TARGET_WEEK}* — DRY RUN, log to memory/logs/${today}.md, exit CONTRIBUTOR_REWARD_DRY_RUN. Do not touch memory/distributions.yml or the state file.
all: mode note: when this phase runs as part of all: with MODE=dry-run, do not notify here and do not exit — hand the computed plan rows straight to Phase B (see Phase C). When all: runs with MODE=execute, continue through A6–A8 normally but replace the trailing Next: line in the A9 notification with Distributing now (phase=all)….
A6. Update memory/distributions.yml (skipped when MODE=dry-run)
Read memory/distributions.yml. If missing → bootstrap with the standard header (matching the send-phase bootstrap style):
defaults:
token: USDC
amount: "5"
chain: base
lists:
Compute the new list block:
contributors-${TARGET_WEEK}: