From ed5cc4a35d74c58f198317d2e327cd01ea58f072 Mon Sep 17 00:00:00 2001 From: Adam Spitz Date: Thu, 20 Aug 2026 18:43:35 -0400 Subject: [PATCH 1/7] Serialize local-stack checks in deep cadence. Share a flock between stack.fresh-seeded and stack.restart-consistency, and skip remaining local-stack cadence checks after a failure so restart cannot overlap a wipe or run against a half-destroyed chain. --- TODO.md | 30 +++++++------------ inbox.md | 6 ++++ package.json | 2 +- scripts/lib/deep-cadence-local-stack.mjs | 27 +++++++++++++++++ scripts/lib/deep-cadence-local-stack.test.mjs | 23 ++++++++++++++ scripts/lib/local-stack-lock.sh | 19 ++++++++++++ scripts/verifier-deep-cadence.mjs | 25 +++++++++++++--- verifier/PLAN.md | 2 +- verifier/README.md | 2 +- verifier/checks/stack/fresh-seeded.sh | 3 ++ verifier/checks/stack/restart-consistency.sh | 3 ++ verifier/commands.json | 2 +- 12 files changed, 116 insertions(+), 28 deletions(-) create mode 100644 scripts/lib/deep-cadence-local-stack.mjs create mode 100644 scripts/lib/deep-cadence-local-stack.test.mjs create mode 100644 scripts/lib/local-stack-lock.sh diff --git a/TODO.md b/TODO.md index 6d45b157b..e8a890ba9 100644 --- a/TODO.md +++ b/TODO.md @@ -33,29 +33,19 @@ When an item from this page is done and no longer needs an LLM implementor's att - Fix the canonical Playwright user journeys (`stack.user-journeys`, exit 1). The content-funding flow reverts in `verifyChannel` with `InvalidVerifierSignature()` (custom error `0x0574e985`) when creating a channel and landing on the creators page, and retries hit the same error. Either the signer/verifier key the E2E harness uses no longer matches the deployed `ChannelRegistry` verifier, or the signed payload's shape/domain changed. -- Stop `stack.fresh-seeded` and `stack.restart-consistency` from racing in the deep - cadence. On 2026-08-19 the nightly run seeded successfully and then destroyed the - result: `stack.fresh-seeded` started 02:15:12 and was still mid-run when - `stack.restart-consistency` began at 02:18:31 (see - `verifier/artifacts/stack.restart-consistency/2026-08-19T06-18-31.768Z-7d0c3d6e/command.log`, - which ends in `Error response from daemon: No such container: 1ffc7816…`). Its - `docker compose stop hardhat-node && up -d` replaced the seeded anvil with an empty - one at 02:19:30; `hardhat-deploy` then redeployed contracts (blocks 1-31) and the - alignment-trust bootstrap wired trust (blocks 32-41), and nothing else ever landed. - Adam woke up to a stack with 48 blocks, 119 logs, and zero cause rosters — while the - cadence summary reported `PASS stack.fresh-seeded`. Both checks are destructive to the - local stack and must be serialized (or share a lock / be placed in a mutually exclusive - supervisor group); a green `fresh-seeded` that another check has since wiped is worse - than a red one. Two sub-issues found alongside it: - (a) `anvil --state /data/state.json` is not giving restart durability — the restarted - anvil came up empty rather than reloading the snapshot, which is also why - `stack.restart-consistency` itself failed. Its comment claims `up -d --no-deps` avoids - rerunning `hardhat-deploy`, but deploy ran anyway. - (b) `stack.fresh-seeded` only probes endpoint reachability, so an unseeded-but-healthy +- `anvil --state /data/state.json` is not giving restart durability. After + `stack.restart-consistency` the node can come up empty rather than reloading the + snapshot (2026-08-19: blocks 1–31 redeploy, 32–41 trust wiring, no seed). The check + comments claim `up -d --no-deps` avoids rerunning `hardhat-deploy`, but deploy ran + anyway. Deep cadence now serializes this check against `stack.fresh-seeded` (shared + flock + skip remaining local-stack checks after a failure); this item is the + remaining anvil/compose durability bug. + +- `stack.fresh-seeded` only probes endpoint reachability, so an unseeded-but-healthy stack passes it. It should assert the seed's own artifacts exist (e.g. the `local-food-systems` / `christianity` roster refs for Hardhat #0 and the `bookmarked-causes` refs for #0-#9), not just that the RPC answers. - Workaround if you hit this again: `./scripts/data.sh --seed=tiny --use-hardhat-accounts + Workaround if you hit an empty seeded stack: `./scripts/data.sh --seed=tiny --use-hardhat-accounts --allow-seed-on-existing-data` reseeds onto the live stack without a wipe. diff --git a/inbox.md b/inbox.md index 5a2553faf..26b3ba295 100644 --- a/inbox.md +++ b/inbox.md @@ -17,6 +17,12 @@ Also, don't let any of the items get too long; usually there's a separate .md fi ## Main list +- **(Tell)** Deep cadence no longer lets `stack.restart-consistency` run alongside or + after a failed `stack.fresh-seeded`. Both checks take a shared `flock` + (`scripts/lib/local-stack-lock.sh`); the cadence runner skips remaining local-stack + checks once one of them fails. Anvil `--state` durability and seed-artifact + assertions are still open in `TODO.md`. + - **(Tell)** Combinator statements are specified and implemented: canonical `all`/`any` over sorted plank CIDs (no title/date), CauseStarter view-strip promote, implication attester structural gate for pairwise arrows only. Ordinary `createStatement` no longer defaults `createdDate` into extras. - **(Tell)** Cause-board **Fully reimbursed** now means success-vouched *and* `outstandingUnreimbursedAmount === 0` (never-scouted successes omitted). It no longer reuses `AlignedProjectsList` with `statusFilterLock="succeeded"` (raised ≥ threshold). New SDK query: `getFullyReimbursedProjectsForCause`. diff --git a/package.json b/package.json index 6e228cbf6..4d888d49b 100644 --- a/package.json +++ b/package.json @@ -119,7 +119,7 @@ "lint:raw": "turbo run lint", "build:raw": "turbo run build", "test:raw": "npm run sdk:test:raw && npm run hardhat:test:raw && npm run integration-tests:raw && npm run ui:test:raw", - "test:fast:raw": "npm run check:docs-inventory && npm run sdk:test:raw && npm run hardhat:test:raw && npm run integration-tests:test:harness:raw && npm run ui:test:vitest:raw", + "test:fast:raw": "npm run check:docs-inventory && node --test scripts/lib/deep-cadence-local-stack.test.mjs && npm run sdk:test:raw && npm run hardhat:test:raw && npm run integration-tests:test:harness:raw && npm run ui:test:vitest:raw", "integration-tests:raw": "./scripts/run-integration-tests.sh", "integration-tests:test:harness:raw": "npm run test:harness --workspace=integration-tests", "sdk:test:raw": "npm run test --workspace=sdk", diff --git a/scripts/lib/deep-cadence-local-stack.mjs b/scripts/lib/deep-cadence-local-stack.mjs new file mode 100644 index 000000000..6dd42170e --- /dev/null +++ b/scripts/lib/deep-cadence-local-stack.mjs @@ -0,0 +1,27 @@ +/** + * Local deep-cadence checks that mutate or depend on exclusive use of the + * Docker stack. Cadence must run them one at a time and skip the rest of this + * set once any of them fail, so stack.restart-consistency cannot start while + * stack.fresh-seeded is still wiping, or against a half-destroyed stack. + */ +export const LOCAL_STACK_CADENCE_CHECK_IDS = [ + 'stack.fresh-seeded', + 'operations.local-stack-health', + 'stack.restart-consistency', + 'operations.indexer-lag', + 'artifact.ipfs-domain-smoke', + 'stack.user-journeys', +] + +export function isFailedCadenceResult(result) { + return Boolean( + result?.signal + || result?.status === 'fail' + || result?.status === 'error' + || (result?.code !== 0 && result?.status !== 'uncertain' && result?.status !== 'skipped'), + ) +} + +export function shouldSkipLocalStackCadenceCheck(checkId, localStackAlreadyFailed) { + return Boolean(localStackAlreadyFailed && LOCAL_STACK_CADENCE_CHECK_IDS.includes(checkId)) +} diff --git a/scripts/lib/deep-cadence-local-stack.test.mjs b/scripts/lib/deep-cadence-local-stack.test.mjs new file mode 100644 index 000000000..65c3c769e --- /dev/null +++ b/scripts/lib/deep-cadence-local-stack.test.mjs @@ -0,0 +1,23 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { + isFailedCadenceResult, + shouldSkipLocalStackCadenceCheck, +} from './deep-cadence-local-stack.mjs' + +test('does not skip local stack checks until one has failed', () => { + assert.equal(shouldSkipLocalStackCadenceCheck('stack.restart-consistency', false), false) +}) + +test('skips restart-consistency after a prior local stack failure', () => { + assert.equal(shouldSkipLocalStackCadenceCheck('stack.restart-consistency', true), true) +}) + +test('does not skip testnet rollups after a local stack failure', () => { + assert.equal(shouldSkipLocalStackCadenceCheck('testnet.environment', true), false) +}) + +test('treats skipped results as non-failures so cadence still reports the original fail', () => { + assert.equal(isFailedCadenceResult({ checkId: 'stack.restart-consistency', code: 0, signal: null, status: 'skipped' }), false) + assert.equal(isFailedCadenceResult({ checkId: 'stack.fresh-seeded', code: 1, signal: null, status: 'fail' }), true) +}) diff --git a/scripts/lib/local-stack-lock.sh b/scripts/lib/local-stack-lock.sh new file mode 100644 index 000000000..47fd26f44 --- /dev/null +++ b/scripts/lib/local-stack-lock.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +# Exclusive lock for checks that mutate the local Docker stack (wipe, seed, +# restart). Source this file, then call acquire_local_stack_lock. +# +# Held for the rest of the shell's lifetime (fd 200). The lock lives outside +# ./data so stack.fresh-seeded's wipe cannot delete it mid-hold. + +acquire_local_stack_lock() { + local lock="${COMMONALITY_LOCAL_STACK_LOCK:-${TMPDIR:-/tmp}/commonality-local-stack.lock}" + if ! command -v flock >/dev/null 2>&1; then + echo "flock is required to serialize local-stack verifier checks." >&2 + return 1 + fi + mkdir -p "$(dirname "$lock")" + exec 200>"$lock" + echo "Acquiring local-stack lock ($lock)..." >&2 + flock 200 + echo "Acquired local-stack lock." >&2 +} diff --git a/scripts/verifier-deep-cadence.mjs b/scripts/verifier-deep-cadence.mjs index 4e1424e0f..091a35bb5 100644 --- a/scripts/verifier-deep-cadence.mjs +++ b/scripts/verifier-deep-cadence.mjs @@ -1,6 +1,10 @@ #!/usr/bin/env node import { spawn } from 'node:child_process' +import { + isFailedCadenceResult, + shouldSkipLocalStackCadenceCheck, +} from './lib/deep-cadence-local-stack.mjs' const args = new Set(process.argv.slice(2)) const includeTestnet = args.has('--testnet') || args.has('--browser-testnet') || args.has('--mutating-testnet') || args.has('--full') @@ -13,7 +17,9 @@ if (args.has('--help') || args.has('-h')) { Runs the guarded deep verifier checks that prove the product boots and reads back. Intended for a nightly/CI job, not for the cheap local development loop. -By default this runs a destructive local rebuild followed by local health/E2E deep checks: +By default this runs a destructive local rebuild followed by local health/E2E deep checks, +one at a time. If a local-stack check fails, later local-stack checks are skipped so +stack.restart-consistency cannot race stack.fresh-seeded or restart a half-wiped chain: - stack.fresh-seeded - operations.local-stack-health - stack.restart-consistency @@ -145,15 +151,26 @@ function runCheck({ checkId, env = {} }) { } const results = [] +let localStackFailed = false for (const check of checks) { - results.push(await runCheck(check)) + if (shouldSkipLocalStackCadenceCheck(check.checkId, localStackFailed)) { + console.error(`\n=== skipping ${check.checkId} (prior local-stack cadence check failed) ===`) + results.push({ checkId: check.checkId, code: 0, signal: null, status: 'skipped' }) + continue + } + const result = await runCheck(check) + results.push(result) + if (shouldSkipLocalStackCadenceCheck(check.checkId, true) && isFailedCadenceResult(result)) { + localStackFailed = true + } } -const failures = results.filter((result) => result.signal || result.status === 'fail' || result.status === 'error' || (result.code !== 0 && result.status !== 'uncertain')) +const failures = results.filter((result) => isFailedCadenceResult(result)) console.error('\n=== deep verifier cadence summary ===') for (const result of results) { const detail = result.signal ? `signal ${result.signal}` : `exit ${result.code}, status ${result.status ?? 'unknown'}` - console.error(`${failures.includes(result) ? 'FAIL' : 'PASS'} ${result.checkId} (${detail})`) + const label = result.status === 'skipped' ? 'SKIP' : (failures.includes(result) ? 'FAIL' : 'PASS') + console.error(`${label} ${result.checkId} (${detail})`) } if (failures.length > 0) { diff --git a/verifier/PLAN.md b/verifier/PLAN.md index 6ddf2bc4e..b238acf10 100644 --- a/verifier/PLAN.md +++ b/verifier/PLAN.md @@ -32,7 +32,7 @@ The backlog below is ordered by how much each item would move the "I actually be `operations.local-stack-health` is now the cheap unguarded canary for the local Dockerized stack: it probes Hardhat RPC, indexer GraphQL, platform API health, and the UI shell, then rolls into `functionality.deep-stack` so a down stack is an explicit functionality failure rather than hidden behind guarded-check staleness. -Nightly local deep cadence is installed on this machine as a user cron job (2:15am daily) via `scripts/verifier-nightly-deep-cadence.sh`. It runs `npm run verifier:deep-cadence` under `flock`, logs to `verifier/logs/nightly-deep-cadence.log`, and emits a log tail to cron stderr on fail/error. The first successful manual run was on 2026-07-06: `stack.fresh-seeded`, `operations.local-stack-health`, `stack.restart-consistency`, `operations.indexer-lag`, `artifact.ipfs-domain-smoke`, `stack.user-journeys`, and `stack.deployment-depth` all passed; functionality rollups remained `uncertain` only because unrelated testnet/ops signals are intentionally not part of the local-only cadence. +Nightly local deep cadence is installed on this machine as a user cron job (2:15am daily) via `scripts/verifier-nightly-deep-cadence.sh`. It runs `npm run verifier:deep-cadence` under `flock`, logs to `verifier/logs/nightly-deep-cadence.log`, and emits a log tail to cron stderr on fail/error. `stack.fresh-seeded` and `stack.restart-consistency` share a local-stack `flock`; cadence skips later local-stack checks after a failure so they cannot overlap. The first successful manual run was on 2026-07-06: `stack.fresh-seeded`, `operations.local-stack-health`, `stack.restart-consistency`, `operations.indexer-lag`, `artifact.ipfs-domain-smoke`, `stack.user-journeys`, and `stack.deployment-depth` all passed; functionality rollups remained `uncertain` only because unrelated testnet/ops signals are intentionally not part of the local-only cadence. ## P0 / P1 — Important remaining work diff --git a/verifier/README.md b/verifier/README.md index e4a98121b..899eae2c2 100644 --- a/verifier/README.md +++ b/verifier/README.md @@ -100,7 +100,7 @@ Run the guarded deep checks from a separate nightly/CI job, for example: 15 2 * * * cd /home/adam/Projects/commonality && npm run verifier:deep-cadence ``` -`verifier:deep-cadence` first opts into `stack.fresh-seeded` to rebuild/seed the local stack, then runs the unguarded `operations.local-stack-health` canary plus the remaining local destructive/E2E stack checks (`stack.restart-consistency`, `operations.indexer-lag`, `artifact.ipfs-domain-smoke`, and `stack.user-journeys`) and refreshes `stack.deployment-depth` and `facet.functionality`, so the dashboard has a retained "the stack really booted" proof. Use `npm run verifier:deep-cadence -- --testnet` for read-only deployed testnet smoke, `npm run verifier:deep-cadence -- --testnet --browser-testnet` to include deployed browser journeys, or `npm run verifier:deep-cadence:full` only in an environment with the funded verifier wallet and mutation credentials. The installed nightly wrapper sources `.env`/`.env.secrets`, runs the read-only testnet smoke plus browser journeys, and includes the mutating on-chain journey only when `COMMONALITY_VERIFIER_NIGHTLY_ALLOW_TESTNET_MUTATION=1` and `COMMONALITY_TESTNET_VERIFIER_PRIVATE_KEY` are present. +`verifier:deep-cadence` first opts into `stack.fresh-seeded` to rebuild/seed the local stack, then runs the unguarded `operations.local-stack-health` canary plus the remaining local destructive/E2E stack checks (`stack.restart-consistency`, `operations.indexer-lag`, `artifact.ipfs-domain-smoke`, and `stack.user-journeys`) and refreshes `stack.deployment-depth` and `facet.functionality`, so the dashboard has a retained "the stack really booted" proof. Those local-stack checks are exclusive: they share a `flock`, cadence runs them one at a time, and a failure skips the rest of the local-stack set so `restart-consistency` cannot wipe a seed that is still being written. Use `npm run verifier:deep-cadence -- --testnet` for read-only deployed testnet smoke, `npm run verifier:deep-cadence -- --testnet --browser-testnet` to include deployed browser journeys, or `npm run verifier:deep-cadence:full` only in an environment with the funded verifier wallet and mutation credentials. The installed nightly wrapper sources `.env`/`.env.secrets`, runs the read-only testnet smoke plus browser journeys, and includes the mutating on-chain journey only when `COMMONALITY_VERIFIER_NIGHTLY_ALLOW_TESTNET_MUTATION=1` and `COMMONALITY_TESTNET_VERIFIER_PRIVATE_KEY` are present. ## Dashboard hierarchy diff --git a/verifier/checks/stack/fresh-seeded.sh b/verifier/checks/stack/fresh-seeded.sh index f36c94f9c..1a95d9739 100644 --- a/verifier/checks/stack/fresh-seeded.sh +++ b/verifier/checks/stack/fresh-seeded.sh @@ -11,6 +11,9 @@ if [ "${COMMONALITY_VERIFIER_ALLOW_DESTRUCTIVE:-}" != "1" ]; then fi cd "$(dirname "$0")/../../.." +# shellcheck source=scripts/lib/local-stack-lock.sh +. ./scripts/lib/local-stack-lock.sh +acquire_local_stack_lock ./scripts/stop-wipe-restart.sh --seed=tiny --use-hardhat-accounts --allow-seed-on-existing-data diff --git a/verifier/checks/stack/restart-consistency.sh b/verifier/checks/stack/restart-consistency.sh index 014fcfc85..5fc41f034 100644 --- a/verifier/checks/stack/restart-consistency.sh +++ b/verifier/checks/stack/restart-consistency.sh @@ -11,6 +11,9 @@ if [ "${COMMONALITY_VERIFIER_ALLOW_RESTART:-}" != "1" ]; then fi cd "$(dirname "$0")/../../.." +# shellcheck source=scripts/lib/local-stack-lock.sh +. ./scripts/lib/local-stack-lock.sh +acquire_local_stack_lock before_events=$(curl --silent --show-error --fail 'http://localhost:42069/api/events?limit=1' 2>&1) || { echo "Could not read indexed events before restart: $before_events" >&2 diff --git a/verifier/commands.json b/verifier/commands.json index 2fd0bd526..0221d5cfe 100644 --- a/verifier/commands.json +++ b/verifier/commands.json @@ -22,7 +22,7 @@ }, { "name": "Deep cadence — local destructive + E2E", - "description": "Nightly/CI-grade local proof that the stack really boots. Runs operations.local-stack-health first, then the guarded checks: stack.fresh-seeded (wipes + reseeds local dev data), restart-consistency, artifact.ipfs-domain-smoke, stack.user-journeys (Playwright round-trips), operations.indexer-lag — then refreshes deployment-depth + facet.functionality so the dashboard retains a fresh end-to-end boot proof. Needs ~20 min and the local Docker stack.", + "description": "Nightly/CI-grade local proof that the stack really boots. Runs stack.fresh-seeded (wipes + reseeds), then local-stack-health, restart-consistency, artifact.ipfs-domain-smoke, stack.user-journeys, operations.indexer-lag — serially, skipping later local-stack checks if one fails — then refreshes deployment-depth + facet.functionality. Needs ~20 min and the local Docker stack.", "command": ["npm", "run", "verifier:deep-cadence"] }, { From 5a745e2eab8bdb4d2f01e0b2245ab54510c91def Mon Sep 17 00:00:00 2001 From: Adam Spitz Date: Thu, 20 Aug 2026 18:51:14 -0400 Subject: [PATCH 2/7] Dump Anvil --state on Docker SIGTERM so restarts keep the chain. PID 1 was ignoring compose stop; the wrapper maps TERM to INT and waits for the dump. Periodic --state-interval plus a block-height assertion in stack.restart-consistency catch empty reloads. --- CONTINUITY.md | 4 ++ TODO.md | 8 ---- docker-compose.yml | 19 ++++++--- inbox.md | 9 +++- scripts/anvil-docker-entrypoint.sh | 30 ++++++++++++++ verifier/checks/stack/restart-consistency.sh | 43 ++++++++++++++++++++ workflow/local-development.md | 2 + 7 files changed, 100 insertions(+), 15 deletions(-) create mode 100755 scripts/anvil-docker-entrypoint.sh diff --git a/CONTINUITY.md b/CONTINUITY.md index 61fc82196..950e42d14 100644 --- a/CONTINUITY.md +++ b/CONTINUITY.md @@ -1778,3 +1778,7 @@ Temporary, reversible: `./scripts/services.sh --start` and `./scripts/deploy-cau ## 2026-08-18 — Faster local seed `./scripts/data.sh --seed` now defaults to **tiny** (was small). `gen:small` and `gen:tiny` both pass `--skip-invariants`. Statement publish reuses one document store (or parallel PublishedData writes across Hardhat wallets, receipts awaited in a batch). Seed RPC clients poll every 50ms. See `fake-data-generation/generateStatements.ts`, `fake-data-generation/seedRpc.ts`, `scripts/data.sh`. + +## 2026-08-20 — Anvil `--state` restart dump + +Local `hardhat-node` was coming back empty after `stack.restart-consistency` because Docker SIGTERM did not make Anvil dump `/data/state.json` (then a fresh deploy + trust wiring filled ~41 blocks with no seed). Fix: `scripts/anvil-docker-entrypoint.sh` maps SIGTERM→SIGINT, compose `--state-interval 15` and 60s grace, restart check records block height and SIGINTs Anvil before stop. Recreate `hardhat-node` to mount the wrapper. diff --git a/TODO.md b/TODO.md index e8a890ba9..e65b3f8a1 100644 --- a/TODO.md +++ b/TODO.md @@ -33,14 +33,6 @@ When an item from this page is done and no longer needs an LLM implementor's att - Fix the canonical Playwright user journeys (`stack.user-journeys`, exit 1). The content-funding flow reverts in `verifyChannel` with `InvalidVerifierSignature()` (custom error `0x0574e985`) when creating a channel and landing on the creators page, and retries hit the same error. Either the signer/verifier key the E2E harness uses no longer matches the deployed `ChannelRegistry` verifier, or the signed payload's shape/domain changed. -- `anvil --state /data/state.json` is not giving restart durability. After - `stack.restart-consistency` the node can come up empty rather than reloading the - snapshot (2026-08-19: blocks 1–31 redeploy, 32–41 trust wiring, no seed). The check - comments claim `up -d --no-deps` avoids rerunning `hardhat-deploy`, but deploy ran - anyway. Deep cadence now serializes this check against `stack.fresh-seeded` (shared - flock + skip remaining local-stack checks after a failure); this item is the - remaining anvil/compose durability bug. - - `stack.fresh-seeded` only probes endpoint reachability, so an unseeded-but-healthy stack passes it. It should assert the seed's own artifacts exist (e.g. the `local-food-systems` / `christianity` roster refs for Hardhat #0 and the diff --git a/docker-compose.yml b/docker-compose.yml index dfff7f203..07326aa0a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,9 +2,11 @@ services: # ============================================================================= # Anvil local blockchain node (from Foundry) # ============================================================================= - # Persists full chain state (blocks + contract storage) via --state flag. + # Persists full chain state (blocks + contract storage) via --state. # On startup: loads /data/state.json if it exists, otherwise starts fresh. - # On exit: dumps chain state to /data/state.json. + # On graceful exit and every --state-interval seconds: dumps to that file. + # The wrapper maps Docker SIGTERM to SIGINT so Anvil actually dumps instead + # of being SIGKILLed after the grace period with an empty/stale snapshot. # Use COMMONALITY_DATA_DIR to configure where data is stored. # Wipe this directory if you want a fresh chain. hardhat-node: @@ -16,9 +18,16 @@ services: volumes: # Persist chain data - set COMMONALITY_DATA_DIR to configure location - ${COMMONALITY_DATA_DIR:-./data}/hardhat:/data - entrypoint: ["anvil"] - command: ["--host", "0.0.0.0", "--state", "/data/state.json"] - stop_grace_period: 30s + - ./scripts/anvil-docker-entrypoint.sh:/entrypoint.sh:ro + entrypoint: ["/bin/sh", "/entrypoint.sh"] + command: + - "--host" + - "0.0.0.0" + - "--state" + - "/data/state.json" + - "--state-interval" + - "15" + stop_grace_period: 60s healthcheck: test: ["CMD-SHELL", "cast block-number --rpc-url http://localhost:8545 || exit 1"] interval: 2s diff --git a/inbox.md b/inbox.md index 26b3ba295..fd221a4f7 100644 --- a/inbox.md +++ b/inbox.md @@ -20,8 +20,13 @@ Also, don't let any of the items get too long; usually there's a separate .md fi - **(Tell)** Deep cadence no longer lets `stack.restart-consistency` run alongside or after a failed `stack.fresh-seeded`. Both checks take a shared `flock` (`scripts/lib/local-stack-lock.sh`); the cadence runner skips remaining local-stack - checks once one of them fails. Anvil `--state` durability and seed-artifact - assertions are still open in `TODO.md`. + checks once one of them fails. Seed-artifact assertions for `stack.fresh-seeded` + are still open in `TODO.md`. + +- **(Tell)** Local Anvil restart durability: `hardhat-node` now uses + `scripts/anvil-docker-entrypoint.sh` (SIGTERM→SIGINT so `--state` dumps), + `--state-interval 15`, and `stack.restart-consistency` asserts the block + height does not drop. Recreate the node to pick up the wrapper. - **(Tell)** Combinator statements are specified and implemented: canonical `all`/`any` over sorted plank CIDs (no title/date), CauseStarter view-strip promote, implication attester structural gate for pairwise arrows only. Ordinary `createStatement` no longer defaults `createdDate` into extras. diff --git a/scripts/anvil-docker-entrypoint.sh b/scripts/anvil-docker-entrypoint.sh new file mode 100755 index 000000000..eced2a4d2 --- /dev/null +++ b/scripts/anvil-docker-entrypoint.sh @@ -0,0 +1,30 @@ +#!/bin/sh +# PID 1 for the local Anvil container. +# +# Docker `compose stop` sends SIGTERM. Anvil dumps `--state` on a graceful +# shutdown (SIGINT / clean exit), but as PID 1 it can ignore SIGTERM until +# Docker SIGKILLs it after stop_grace_period — so the next start loads nothing +# and looks like a fresh chain. Forward TERM as INT and *keep waiting* until +# Anvil exits; a single `wait` returns as soon as the trap fires, which used +# to tear the wrapper down before the dump finished. + +set -eu + +pid="" + +forward_int() { + if [ -n "$pid" ]; then + kill -INT "$pid" 2>/dev/null || true + fi +} + +trap forward_int INT TERM + +anvil "$@" & +pid=$! + +# `wait` is interrupted by the trap; loop until the child is actually gone. +while kill -0 "$pid" 2>/dev/null; do + wait "$pid" || true +done +exit 0 diff --git a/verifier/checks/stack/restart-consistency.sh b/verifier/checks/stack/restart-consistency.sh index 5fc41f034..0bbfc3a96 100644 --- a/verifier/checks/stack/restart-consistency.sh +++ b/verifier/checks/stack/restart-consistency.sh @@ -36,11 +36,43 @@ docker_compose() { export UID export GID=$(id -g) +rpc_block_number() { + local hex + hex=$(curl --silent --show-error --fail -X POST -H "Content-Type: application/json" \ + --data '{"jsonrpc":"2.0","method":"eth_blockNumber","params":[],"id":1}' \ + http://localhost:8545 | sed -n 's/.*"result":"\([^"]*\)".*/\1/p') + [ -n "$hex" ] || return 1 + printf '%d' "$((hex))" +} + +before_block=$(rpc_block_number) || { + echo "Could not read eth_blockNumber before restart." >&2 + exit 3 +} +if [ "$before_block" -lt 1 ]; then + echo "Local chain was still at genesis before restart; seed the stack first." >&2 + exit 3 +fi + # Restart the already-deployed local stack without rerunning hardhat-deploy. # `scripts/services.sh --stop && --start` recreates the deploy container, which # deploys fresh contracts on the persisted chain and rewrites .env; the indexer # then watches the new addresses rather than the seeded events we are trying to # prove survived restart. +# +# Send SIGINT to Anvil first so `--state` dumps; `compose stop` alone is SIGTERM +# and used to leave an empty chain (fresh deploy + trust wiring, no seed). +docker_compose kill -s INT hardhat-node >/dev/null 2>&1 || true +waited=0 +while [ "$waited" -lt 60 ]; do + running=$(docker inspect -f '{{.State.Running}}' commonality-hardhat-node 2>/dev/null || echo false) + if [ "$running" != "true" ]; then + break + fi + sleep 1 + waited=$((waited + 1)) +done + docker_compose stop ui-local-gateway indexer platform-api-service ipfs hardhat-node docker_compose up -d --no-deps hardhat-node ipfs platform-api-service indexer ui-local-gateway @@ -96,6 +128,17 @@ else add_evidence post-restart-indexed-events fail "No indexed event was visible after restart before timeout." fi +after_block="" +if after_block=$(rpc_block_number); then + if [ "$after_block" -ge "$before_block" ]; then + add_evidence post-restart-block-number pass "Chain height after restart was ${after_block} (was ${before_block})." + else + add_evidence post-restart-block-number fail "Chain height dropped from ${before_block} to ${after_block}; Anvil did not reload --state." + fi +else + add_evidence post-restart-block-number fail "Could not read eth_blockNumber after restart." +fi + probe rpc "Local Hardhat RPC answered after restart." "Local Hardhat RPC did not answer after restart." \ curl --silent --show-error --fail -X POST -H "Content-Type: application/json" --data '{"jsonrpc":"2.0","method":"eth_blockNumber","params":[],"id":1}' http://localhost:8545 probe platform-api "Platform API health endpoint answered after restart." "Platform API health endpoint did not answer after restart." \ diff --git a/workflow/local-development.md b/workflow/local-development.md index 88bb3dad2..04fbe7f42 100644 --- a/workflow/local-development.md +++ b/workflow/local-development.md @@ -44,6 +44,8 @@ For a clean local reset, use: ./scripts/data.sh --seed ``` +The Anvil container (`hardhat-node`) persists blocks to `data/hardhat/state.json` via `--state` plus a 15s `--state-interval`. Docker stop is SIGTERM; a small entrypoint (`scripts/anvil-docker-entrypoint.sh`) forwards that as SIGINT so Anvil dumps instead of dying empty. Recreate the node after changing that compose service (`docker compose up -d --force-recreate --no-deps hardhat-node`) so the wrapper is mounted. + `--wipe` removes the saved local chain, IPFS repo, and Ponder indexer database. Do not delete only one of `data/hardhat/` or `data/ponder/`: a reset chain with an old Ponder database can make the UI look empty because the indexer thinks old blocks were already processed. `services.sh --start` clears Ponder automatically when it sees Ponder data without a saved local chain, and `data.sh --seed` now errors if the indexer already contains events. If you intentionally want to add another seed run on top of existing data, pass `--allow-seed-on-existing-data`. For a richer first-run demo that uses the formal seed-content corpus (excluding proliferation variants) and publishes one-shot Explorer/nudge fixtures without live AI worker calls, run: From aa22b9809382c789124fb1774f5177dae13443e1 Mon Sep 17 00:00:00 2001 From: Adam Spitz Date: Thu, 20 Aug 2026 19:14:29 -0400 Subject: [PATCH 3/7] Assert tiny-seed CauseStarter refs in stack.fresh-seeded. Reachable endpoints no longer pass the check; it also requires Hardhat #0 roster refs and bookmarked-causes for the funded seed wallets. --- TODO.md | 9 -- inbox.md | 9 +- verifier/README.md | 2 +- verifier/checks/stack/fresh-seeded.def.json | 2 +- verifier/checks/stack/fresh-seeded.sh | 4 +- verifier/checks/stack/probe-seed-refs.mjs | 105 ++++++++++++++++++++ 6 files changed, 115 insertions(+), 16 deletions(-) create mode 100644 verifier/checks/stack/probe-seed-refs.mjs diff --git a/TODO.md b/TODO.md index e65b3f8a1..9d6f0c131 100644 --- a/TODO.md +++ b/TODO.md @@ -33,15 +33,6 @@ When an item from this page is done and no longer needs an LLM implementor's att - Fix the canonical Playwright user journeys (`stack.user-journeys`, exit 1). The content-funding flow reverts in `verifyChannel` with `InvalidVerifierSignature()` (custom error `0x0574e985`) when creating a channel and landing on the creators page, and retries hit the same error. Either the signer/verifier key the E2E harness uses no longer matches the deployed `ChannelRegistry` verifier, or the signed payload's shape/domain changed. -- `stack.fresh-seeded` only probes endpoint reachability, so an unseeded-but-healthy - stack passes it. It should assert the seed's own artifacts exist (e.g. the - `local-food-systems` / `christianity` roster refs for Hardhat #0 and the - `bookmarked-causes` refs for #0-#9), not just that the RPC answers. - Workaround if you hit an empty seeded stack: `./scripts/data.sh --seed=tiny --use-hardhat-accounts - --allow-seed-on-existing-data` reseeds onto the live stack without a wipe. - - - - [ ] **(Tell)** Measure whether the proposed planks/views model can fold `DirectSupport` events per plank client-side at approximately 10⁵ signers, or whether it needs a server-side fold. This is currently an unmeasured assertion in [shaping-your-cause-statements.md](docs/founder/shaping-your-cause-statements.md). Report the setup, timings, memory/browser behavior, and conclusion; do not build the server-side path yet. - Verify the new local public-goods demo-seed storyline against a live stack. `PROJECT_SEED_METADATA[0]` is now "Riverside Community Garden" (aligned to `fundable-projects`/`local-community`/`local-food-systems`), `DETERMINISTIC_SEED_PROJECT_ALIGNMENT_COUNT` is 6 so no existing storyline lost its alignment, and `gen:seed:local` runs 12 users to keep the success-attester pool satisfied. Unit tests pass, but the seed has still never been run end-to-end: `stack.fresh-seeded` now passes (2026-08-03) but it seeds `tiny`, not `demo`. Run `./scripts/data.sh --wipe && ./scripts/data.sh --seed=demo` and confirm in the UI that the garden project shows an alignment vouch, contributions, and a success attestation. Consider also regenerating `data/seed-worker-outputs.json` if the Explorer fixture should mention the new cause. diff --git a/inbox.md b/inbox.md index fd221a4f7..e288808f6 100644 --- a/inbox.md +++ b/inbox.md @@ -20,8 +20,11 @@ Also, don't let any of the items get too long; usually there's a separate .md fi - **(Tell)** Deep cadence no longer lets `stack.restart-consistency` run alongside or after a failed `stack.fresh-seeded`. Both checks take a shared `flock` (`scripts/lib/local-stack-lock.sh`); the cadence runner skips remaining local-stack - checks once one of them fails. Seed-artifact assertions for `stack.fresh-seeded` - are still open in `TODO.md`. + checks once one of them fails. + +- **(Tell)** `stack.fresh-seeded` now fails if the tiny seed's CauseStarter refs + are missing: Hardhat #0 `local-food-systems` / `christianity` rosters and + `bookmarked-causes` for Hardhat #0–#9 (`verifier/checks/stack/probe-seed-refs.mjs`). - **(Tell)** Local Anvil restart durability: `hardhat-node` now uses `scripts/anvil-docker-entrypoint.sh` (SIGTERM→SIGINT so `--state` dumps), @@ -32,8 +35,6 @@ Also, don't let any of the items get too long; usually there's a separate .md fi - **(Tell)** Cause-board **Fully reimbursed** now means success-vouched *and* `outstandingUnreimbursedAmount === 0` (never-scouted successes omitted). It no longer reuses `AlignedProjectsList` with `statusFilterLock="succeeded"` (raised ≥ threshold). New SDK query: `getFullyReimbursedProjectsForCause`. - - ### Security/recoverability human actions - Replace/scopedown external account tokens: Cloudflare scoped DNS token instead of global key; Render/Pinata scoped as narrowly as possible; OpenRouter spend limit. diff --git a/verifier/README.md b/verifier/README.md index 899eae2c2..c56636d53 100644 --- a/verifier/README.md +++ b/verifier/README.md @@ -63,7 +63,7 @@ To run a manual/LLM validation pass (intelligent judgment when conventional test Guarded checks refuse to run without an explicit opt-in env var. **Each has its own — they are NOT interchangeable.** (`coverage/guarded-check-policy.json` is the authoritative per-check list; this is the operator's how-to.) -- **`stack.fresh-seeded`** — `COMMONALITY_VERIFIER_ALLOW_DESTRUCTIVE=1`. Self-contained: wipes local data, rebuilds Docker images, restarts services, seeds tiny data, then probes rpc / platform-api / ipfs / indexer-graphql / indexer-events. This **is** how you "boot the local stack." ~5–8 min (image build dominates). +- **`stack.fresh-seeded`** — `COMMONALITY_VERIFIER_ALLOW_DESTRUCTIVE=1`. Self-contained: wipes local data, rebuilds Docker images, restarts services, seeds tiny data, then probes rpc / platform-api / ipfs / indexer-graphql / indexer-events and asserts the tiny seed's CauseStarter refs (`local-food-systems` / `christianity` for Hardhat #0, `bookmarked-causes` for #0–#9). This **is** how you "boot the local stack." ~5–8 min (image build dominates). - **`stack.restart-consistency`** — `COMMONALITY_VERIFIER_ALLOW_RESTART=1` (**not** the destructive flag). Requires a live seeded stack with an indexed event already visible; its pre-restart probe exits fast if the indexer (port 42069) is down. Run it right after `fresh-seeded` **in the same session** — a stack left down between the two makes it false-fail with `curl` exit 7. - **`testnet.*`** (live deployed testnet) — needs `COMMONALITY_VERIFIER_ENABLE_TESTNET_SMOKE=1` **and** `COMMONALITY_TESTNET_RPC_URL`. Write journeys (`testnet.onchain-to-indexer`) additionally need `COMMONALITY_VERIFIER_ENABLE_TESTNET_MUTATION=1`. Don't set these by hand — the `verifier:testnet:run` wrapper (`scripts/verifier-testnet.sh`) supplies them from secrets. diff --git a/verifier/checks/stack/fresh-seeded.def.json b/verifier/checks/stack/fresh-seeded.def.json index 7f012552b..6799c106d 100644 --- a/verifier/checks/stack/fresh-seeded.def.json +++ b/verifier/checks/stack/fresh-seeded.def.json @@ -1,6 +1,6 @@ { "id": "stack.fresh-seeded", - "description": "Release-candidate smoke: explicitly opt-in, wipe local dev data, restart services, seed a tiny dataset, and verify core local endpoints.", + "description": "Release-candidate smoke: explicitly opt-in, wipe local dev data, restart services, seed a tiny dataset, verify core local endpoints, and assert tiny-seed CauseStarter roster/bookmark refs exist on chain.", "trigger": { "type": "manual" }, "retention": { "keep": 3, "keepDays": 30 }, "command": ["node", "checks/stack/guarded-command-check.mjs"], diff --git a/verifier/checks/stack/fresh-seeded.sh b/verifier/checks/stack/fresh-seeded.sh index 1a95d9739..00b00d1d6 100644 --- a/verifier/checks/stack/fresh-seeded.sh +++ b/verifier/checks/stack/fresh-seeded.sh @@ -71,6 +71,8 @@ probe indexer-events "Indexer events API returned at least one indexed event." " wait_for_indexed_event probe services-url "Service URL summary command completed." "Service URL summary command failed." \ ./scripts/services.sh --url +probe seed-roster-refs "Tiny-seed CauseStarter roster and bookmark refs are present on chain." "Tiny-seed CauseStarter roster or bookmark refs are missing on chain." \ + node verifier/checks/stack/probe-seed-refs.mjs if [ -n "${COMMONALITY_VERIFIER_HEALTH_EVIDENCE_FILE:-}" ]; then mkdir -p "$(dirname "$COMMONALITY_VERIFIER_HEALTH_EVIDENCE_FILE")" @@ -84,4 +86,4 @@ if [ "$OVERALL_FAIL" -ne 0 ]; then exit 1 fi -echo "Fresh seeded stack smoke passed. Mutated state: stopped services, wiped ./data (or COMMONALITY_DATA_DIR), restarted services, seeded tiny fake data with hardhat accounts, republished local IPFS domain UI artifacts." +echo "Fresh seeded stack smoke passed. Mutated state: stopped services, wiped ./data (or COMMONALITY_DATA_DIR), restarted services, seeded tiny fake data with hardhat accounts, republished local IPFS domain UI artifacts. Seed artifacts: Hardhat #0 local-food-systems and christianity roster refs plus bookmarked-causes for Hardhat #0-#9." diff --git a/verifier/checks/stack/probe-seed-refs.mjs b/verifier/checks/stack/probe-seed-refs.mjs new file mode 100644 index 000000000..25fd58359 --- /dev/null +++ b/verifier/checks/stack/probe-seed-refs.mjs @@ -0,0 +1,105 @@ +/** + * Assert tiny-seed CauseStarter artifacts exist on the local chain. + * Reads MutableRefUpdater.getRef so an unseeded-but-reachable stack fails. + */ +import { readFile } from "node:fs/promises"; +import { createPublicClient, getAddress, http } from "viem"; +import { privateKeyToAccount } from "viem/accounts"; + +const RPC_URL = process.env.RPC_URL ?? "http://localhost:8545"; +/** Same funded Hardhat keys the tiny seed bookmarks (`FUNDED_HARDHAT_DEV_KEYS`). */ +const FUNDED_HARDHAT_DEV_KEYS = [ + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", + "0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d", + "0x5de4111afa1a4b94908f83103eb1f1706367c2e68ca870fc3fb9a804cdab365a", + "0x7c852118294e51e653712a81e05800f419141751be58f605c371e15141b007a6", + "0x47e179ec197488593b187f80a00eb0da91f1b9d0b13f8733639f19c30a34926a", + "0x8b3a350cf5c34c9194ca85829a2df0ec3153be0318b5e2d3348e872092edffba", + "0x92db14e403b83dfe3df233f83dfa3a0d7096f21ca9b0d6d6b8d88b2b4ec1564e", + "0x4bbbf85ce3377467afe5d46f804f221813b2bb87f24d81f60f1fcdbf7cbf4356", + "0xdbda1821b80551c9d65939329250298aa3472ba22feea921c0cf5d620ea67b97", + "0x2a871d0798f97d79848a013d4936a73bf4cc922c825d33c1cf7073dff6d409c6" +]; +const HARDHAT_ACCOUNTS = FUNDED_HARDHAT_DEV_KEYS.map((key) => privateKeyToAccount(key).address); + +const GET_REF_ABI = [ + { + type: "function", + name: "getRef", + stateMutability: "view", + inputs: [ + { name: "owner", type: "address" }, + { name: "name", type: "string" } + ], + outputs: [{ name: "", type: "string" }] + } +]; + +function parseEnvFile(text) { + const env = {}; + for (const line of text.split("\n")) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) continue; + const eq = trimmed.indexOf("="); + if (eq <= 0) continue; + env[trimmed.slice(0, eq)] = trimmed.slice(eq + 1).trim(); + } + return env; +} + +async function loadUpdaterAddress() { + if (process.env.MUTABLE_REF_UPDATER_CONTRACT_ADDRESS) { + return process.env.MUTABLE_REF_UPDATER_CONTRACT_ADDRESS; + } + const text = await readFile("deployments/localhost.env", "utf8"); + const env = parseEnvFile(text); + const address = env.MUTABLE_REF_UPDATER_CONTRACT_ADDRESS ?? env.MUTABLE_REF_UPDATER_ADDRESS; + if (!address) { + throw new Error("MUTABLE_REF_UPDATER_CONTRACT_ADDRESS missing from deployments/localhost.env"); + } + return address; +} + +async function main() { + const address = await loadUpdaterAddress(); + const client = createPublicClient({ + transport: http(RPC_URL) + }); + + const probes = [ + { owner: HARDHAT_ACCOUNTS[0], name: "local-food-systems", label: "Hardhat #0 local-food-systems roster" }, + { owner: HARDHAT_ACCOUNTS[0], name: "christianity", label: "Hardhat #0 christianity roster" }, + ...HARDHAT_ACCOUNTS.map((owner, index) => ({ + owner, + name: "bookmarked-causes", + label: `Hardhat #${index} bookmarked-causes` + })) + ]; + + const missing = []; + for (const probe of probes) { + const value = await client.readContract({ + address: getAddress(address), + abi: GET_REF_ABI, + functionName: "getRef", + args: [getAddress(probe.owner.toLowerCase()), probe.name] + }); + if (typeof value !== "string" || value.length === 0) { + missing.push(probe.label); + } + } + + if (missing.length > 0) { + console.error(`Seed artifacts missing: ${missing.join("; ")}`); + process.exit(1); + } + + console.error( + `Seed artifacts present: Hardhat #0 local-food-systems and christianity rosters; bookmarked-causes for Hardhat #0-#9.` + ); +} + +main().catch((error) => { + console.error(error?.message ?? String(error)); + process.exit(1); +}); From 61feb8b9ee6aeb38ad6b48a22437a9ea7dbbc8a2 Mon Sep 17 00:00:00 2001 From: Adam Spitz Date: Thu, 20 Aug 2026 19:16:23 -0400 Subject: [PATCH 4/7] Removed Tell things. --- inbox.md | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/inbox.md b/inbox.md index e288808f6..b717ccebb 100644 --- a/inbox.md +++ b/inbox.md @@ -17,24 +17,6 @@ Also, don't let any of the items get too long; usually there's a separate .md fi ## Main list -- **(Tell)** Deep cadence no longer lets `stack.restart-consistency` run alongside or - after a failed `stack.fresh-seeded`. Both checks take a shared `flock` - (`scripts/lib/local-stack-lock.sh`); the cadence runner skips remaining local-stack - checks once one of them fails. - -- **(Tell)** `stack.fresh-seeded` now fails if the tiny seed's CauseStarter refs - are missing: Hardhat #0 `local-food-systems` / `christianity` rosters and - `bookmarked-causes` for Hardhat #0–#9 (`verifier/checks/stack/probe-seed-refs.mjs`). - -- **(Tell)** Local Anvil restart durability: `hardhat-node` now uses - `scripts/anvil-docker-entrypoint.sh` (SIGTERM→SIGINT so `--state` dumps), - `--state-interval 15`, and `stack.restart-consistency` asserts the block - height does not drop. Recreate the node to pick up the wrapper. - -- **(Tell)** Combinator statements are specified and implemented: canonical `all`/`any` over sorted plank CIDs (no title/date), CauseStarter view-strip promote, implication attester structural gate for pairwise arrows only. Ordinary `createStatement` no longer defaults `createdDate` into extras. - -- **(Tell)** Cause-board **Fully reimbursed** now means success-vouched *and* `outstandingUnreimbursedAmount === 0` (never-scouted successes omitted). It no longer reuses `AlignedProjectsList` with `statusFilterLock="succeeded"` (raised ≥ threshold). New SDK query: `getFullyReimbursedProjectsForCause`. - ### Security/recoverability human actions - Replace/scopedown external account tokens: Cloudflare scoped DNS token instead of global key; Render/Pinata scoped as narrowly as possible; OpenRouter spend limit. From b93182257ea8647a582ef455e4c5f70d71d211e2 Mon Sep 17 00:00:00 2001 From: Adam Spitz Date: Thu, 20 Aug 2026 19:24:44 -0400 Subject: [PATCH 5/7] Index ProjectCreated so CauseStarter can list created projects. Subscribe the event cache to ProjectFactory.ProjectCreated and query it by creator topic instead of unbounded eth_getLogs from block 0. --- TODO.md | 2 - causestarter/TODO.md | 1 - causestarter/src/lib/userProjects.test.ts | 22 ++-- causestarter/src/lib/userProjects.ts | 24 +--- inbox.md | 4 + indexer/ponder.config.ts | 9 ++ indexer/src/events-cache/index.ts | 1 + render.yaml | 2 + render.yaml.template | 2 + scripts/deployment-manifest.mjs | 1 + .../lazy-giving/queries.created.test.ts | 119 ++++++++++++++++++ sdk/src/subsystems/lazy-giving/queries.ts | 25 ++++ sdk/src/utils/eventDecoder.test.ts | 38 ++++++ sdk/src/utils/eventDecoder.ts | 31 +++++ specs/tech/subsystems/aligning/indexer.md | 2 +- 15 files changed, 245 insertions(+), 38 deletions(-) create mode 100644 sdk/src/subsystems/lazy-giving/queries.created.test.ts diff --git a/TODO.md b/TODO.md index 9d6f0c131..bff04719d 100644 --- a/TODO.md +++ b/TODO.md @@ -10,8 +10,6 @@ When an item from this page is done and no longer needs an LLM implementor's att ---- -- **(Tell)** Index `ProjectFactory.ProjectCreated` (or add a creator-filtered SDK query) so CauseStarter’s “projects you created” list does not depend on `eth_getLogs` from block 0. Tracked in [`causestarter/TODO.md`](causestarter/TODO.md). Public RPCs often reject unbounded log ranges and the current catch returns `[]`. - - Add a fresh-stack integration test for the alignment-trust bootstrap: publish an alignment vouch from a previously unknown wallet, observe the service's `TrustSet(..., 100)`, confirm a wallet with no personal graph sees that vouch diff --git a/causestarter/TODO.md b/causestarter/TODO.md index 10e690814..141024945 100644 --- a/causestarter/TODO.md +++ b/causestarter/TODO.md @@ -37,7 +37,6 @@ open **if they stay listed here**. - [ ] Safety filter is MVP/heuristic + LLM policy text — not legal-grade; version/align with operator/legal specs later. - [ ] Unpublished draft state still in `localStorage` only — multi-device recovery of *drafts* later (published rosters are on chain; published *bookmarks* follow the wallet `bookmarked-causes` ref). -- [ ] **Created-project list uses `eth_getLogs` from block 0.** Home “Projects” (`causestarter/src/lib/userProjects.ts`) looks up `ProjectCreated` on the factory with `fromBlock: 0n`, then swallows RPC errors. Public RPCs often reject unbounded log ranges, so “created” silently disappears while contributed/bookmarked still show. The indexer does not currently subscribe to `ProjectFactory.ProjectCreated` (only `LazyGivingAssuranceContractCreated`, which has no creator topic). Index that event (or add a creator-filtered SDK query) and stop using a full-history `getLogs`. - [ ] Cause bookmarks: `bookmarked-causes` is a public wallet `updateRef`; the only user-facing warning is the Causes list-page disclaimer. Keep/remove, reconnect hydrate, and tombstoned union-sync (a later keep can restore) are in place; Playwright covers keep/remove + reconnect. - [ ] **Project bookmarks are last-local-wins.** `bookmarked-projects` hydrates only when this device has no local key, then `persist` writes the local list over the wallet ref. A second device that bookmarked in between is clobbered. An in-flight hydrate no longer overwrites a click that landed during `getUserRef`. Reuse cause-bookmark keep/removed tombstones (or merge-before-write) before this is more than a personal list. - [ ] Statement `bookmarks` ref is still reserved infrastructure only — no CauseStarter (or main `ui`) surface for remembering a statement without signing it. diff --git a/causestarter/src/lib/userProjects.test.ts b/causestarter/src/lib/userProjects.test.ts index 5f06fad34..91f1d3cef 100644 --- a/causestarter/src/lib/userProjects.test.ts +++ b/causestarter/src/lib/userProjects.test.ts @@ -1,26 +1,23 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { loadUserProjects } from './userProjects' -const { getProject, getUserContributions, readLazyGivingProjectMetadata, getRuntimeConfigValue } = vi.hoisted(() => ({ +const { getProject, getUserContributions, getUserCreatedProjects, readLazyGivingProjectMetadata } = vi.hoisted(() => ({ getProject: vi.fn(), getUserContributions: vi.fn(), + getUserCreatedProjects: vi.fn(), readLazyGivingProjectMetadata: vi.fn(), - getRuntimeConfigValue: vi.fn(), })) vi.mock('@commonality/sdk/lazy-giving', () => ({ getProject, getUserContributions, + getUserCreatedProjects, })) vi.mock('@ui/lazy-giving/metadata', () => ({ readLazyGivingProjectMetadata, })) -vi.mock('./runtimeConfig', () => ({ - getRuntimeConfigValue, -})) - const USER = '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' const PROJECT = '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' @@ -28,8 +25,8 @@ describe('loadUserProjects', () => { beforeEach(() => { vi.clearAllMocks() window.localStorage.clear() - getRuntimeConfigValue.mockReturnValue('0xcccccccccccccccccccccccccccccccccccccccc') getUserContributions.mockResolvedValue([]) + getUserCreatedProjects.mockResolvedValue([]) getProject.mockResolvedValue({ id: PROJECT, metadataCid: 'bafy1', @@ -42,19 +39,16 @@ describe('loadUserProjects', () => { it('includes contributed projects', async () => { getUserContributions.mockResolvedValue([{ projectAddress: PROJECT }]) - const machinery = { publicClient: { getLogs: vi.fn().mockResolvedValue([]) } } + const machinery = {} const rows = await loadUserProjects(machinery as never, USER) expect(rows).toHaveLength(1) expect(rows[0]?.title).toBe('Garden beds') expect(rows[0]?.relations).toEqual(['contributed']) }) - it('includes created projects from factory logs', async () => { - const machinery = { - publicClient: { - getLogs: vi.fn().mockResolvedValue([{ args: { assuranceContract: PROJECT } }]), - }, - } + it('includes created projects from indexed ProjectCreated events', async () => { + getUserCreatedProjects.mockResolvedValue([PROJECT]) + const machinery = {} const rows = await loadUserProjects(machinery as never, USER) expect(rows[0]?.relations).toEqual(['created']) }) diff --git a/causestarter/src/lib/userProjects.ts b/causestarter/src/lib/userProjects.ts index 4bacbf50a..a4c767b6a 100644 --- a/causestarter/src/lib/userProjects.ts +++ b/causestarter/src/lib/userProjects.ts @@ -1,16 +1,10 @@ -import { parseAbiItem } from 'viem' -import { getProject, getUserContributions, type Project } from '@commonality/sdk/lazy-giving' +import { getProject, getUserContributions, getUserCreatedProjects, type Project } from '@commonality/sdk/lazy-giving' import type { SDKMachinery } from '@commonality/sdk/machinery' import { readLazyGivingProjectMetadata } from '@ui/lazy-giving/metadata' import type { IpfsCidV1 } from '@commonality/sdk/utils' import { mapWithConcurrency, PLANK_QUERY_CONCURRENCY } from './concurrency' -import { getRuntimeConfigValue } from './runtimeConfig' import { listProjectBookmarks } from './projectBookmarks' -const PROJECT_CREATED_EVENT = parseAbiItem( - 'event ProjectCreated(address indexed creator, address indexed token, address indexed assuranceContract, address condition)', -) - export type ProjectRelation = 'created' | 'contributed' | 'bookmarked' export interface UserProject { @@ -29,20 +23,10 @@ async function createdProjectAddresses( machinery: SDKMachinery, userAddress: string, ): Promise { - const factory = getRuntimeConfigValue('VITE_PROJECT_FACTORY_CONTRACT_ADDRESS') as `0x${string}` | undefined - const publicClient = machinery.publicClient as - | { getLogs: (args: unknown) => Promise> } - | undefined - if (!factory || !publicClient?.getLogs) return [] try { - const logs = await publicClient.getLogs({ - address: factory, - event: PROJECT_CREATED_EVENT, - args: { creator: userAddress as `0x${string}` }, - fromBlock: 0n, - }) - return logs - .map((log) => normalizeAddress(log.args?.assuranceContract ?? '')) + const addresses = await getUserCreatedProjects(machinery, userAddress) + return addresses + .map((address) => normalizeAddress(address)) .filter((address): address is string => Boolean(address)) } catch { return [] diff --git a/inbox.md b/inbox.md index b717ccebb..347985a38 100644 --- a/inbox.md +++ b/inbox.md @@ -17,6 +17,10 @@ Also, don't let any of the items get too long; usually there's a separate .md fi ## Main list +### Tell — done after the fact + +- Indexed `ProjectFactory.ProjectCreated` in the event cache and switched CauseStarter’s “projects you created” list to `getUserCreatedProjects` (creator-filtered by topic1). No more `eth_getLogs` from block 0. Hosted indexer needs `PROJECT_FACTORY_ADDRESS` (added in `render.yaml`; also in the deployment-manifest builder). Existing stacks must reindex that contract to populate the new events. + ### Security/recoverability human actions - Replace/scopedown external account tokens: Cloudflare scoped DNS token instead of global key; Render/Pinata scoped as narrowly as possible; OpenRouter spend limit. diff --git a/indexer/ponder.config.ts b/indexer/ponder.config.ts index f8c5a9403..fd578716f 100644 --- a/indexer/ponder.config.ts +++ b/indexer/ponder.config.ts @@ -10,6 +10,7 @@ import { AssuranceContractFactoryAbi, PremintingERC1155FactoryAbi, } from "./abis/ProjectFactoriesAbi"; +import { ProjectFactoryAbi } from "./abis/ProjectFactoryAbi"; import { MultiERC1155AssuranceContractAbi as AssuranceContractAbi } from "./abis/AssuranceContractAbi"; import { PremintingERC1155Abi } from "./abis/PremintingERC1155Abi"; @@ -192,6 +193,7 @@ function factoryAddress(deployments: ContractDeployment[]) { const BELIEFS_DEPLOYMENTS = getDeployments("Beliefs", "BELIEFS_CONTRACT_ADDRESS", START_BLOCK); const IMPLICATIONS_DEPLOYMENTS = getDeployments("Implications", "IMPLICATIONS_CONTRACT_ADDRESS", START_BLOCK); const ASSURANCE_CONTRACT_FACTORY_DEPLOYMENTS = getDeployments("AssuranceContractFactory", "ASSURANCE_CONTRACT_FACTORY_ADDRESS", LAZYGIVING_START_BLOCK); +const PROJECT_FACTORY_DEPLOYMENTS = getDeployments("ProjectFactory", "PROJECT_FACTORY_ADDRESS", LAZYGIVING_START_BLOCK); const ERC1155_FACTORY_DEPLOYMENTS = getDeployments("ERC1155Factory", "ERC1155_FACTORY_ADDRESS", LAZYGIVING_START_BLOCK); const DELEGATABLE_NOTES_DEPLOYMENTS = getDeployments("DelegatableNotes", "DELEGATABLE_NOTES_ADDRESS", DELEGATION_START_BLOCK); const RECURRING_PLEDGES_DEPLOYMENTS = getDeployments("RecurringPledges", "RECURRING_PLEDGES_ADDRESS", DELEGATION_START_BLOCK); @@ -247,6 +249,13 @@ const contracts = { ...deploymentConfig(ASSURANCE_CONTRACT_FACTORY_DEPLOYMENTS, LAZYGIVING_START_BLOCK), }, + // ProjectFactory emits ProjectCreated with an indexed creator topic + ProjectFactory: { + abi: ProjectFactoryAbi, + chain: chainForContract("default"), + ...deploymentConfig(PROJECT_FACTORY_DEPLOYMENTS, LAZYGIVING_START_BLOCK), + }, + // Factory contract for creating ERC1155 tokens ERC1155Factory: { abi: PremintingERC1155FactoryAbi, diff --git a/indexer/src/events-cache/index.ts b/indexer/src/events-cache/index.ts index e85885fc9..344376001 100644 --- a/indexer/src/events-cache/index.ts +++ b/indexer/src/events-cache/index.ts @@ -31,6 +31,7 @@ register("Implications:ImplicationAttestation"); // LAZYGIVING: Factory + AssuranceContract + non-transferable ERC1155 receipts register("AssuranceContractFactory:LazyGivingAssuranceContractCreated"); +register("ProjectFactory:ProjectCreated"); register("ERC1155Factory:LazyGivingERC1155ContractCreated"); register("AssuranceContract:AssuranceContractInitialized"); register("AssuranceContract:ContractMetadataUpdated"); diff --git a/render.yaml b/render.yaml index 0b958ca8e..b5e287753 100644 --- a/render.yaml +++ b/render.yaml @@ -181,6 +181,8 @@ services: value: "0x3991162F03F888f52FB2C655024Ba787F39A1367" - key: ASSURANCE_CONTRACT_FACTORY_ADDRESS value: "0x01163293b1Fa49Acb7276C242438FDd34ad8Ca48" + - key: PROJECT_FACTORY_ADDRESS + value: "0x08Ff21013752D50eD30a5f93B1F78607CaF48e10" - key: ERC1155_FACTORY_ADDRESS value: "0x6384Bb3Df1cbbd2da785e84909eB6F205d404eaD" - key: DELEGATABLE_NOTES_ADDRESS diff --git a/render.yaml.template b/render.yaml.template index ab0e49fcd..110c46f7c 100644 --- a/render.yaml.template +++ b/render.yaml.template @@ -184,6 +184,8 @@ services: sync: false # from-env: ACCOUNT_ASSERTIONS_ADDRESS - key: ASSURANCE_CONTRACT_FACTORY_ADDRESS sync: false # from-env: ASSURANCE_CONTRACT_FACTORY_ADDRESS + - key: PROJECT_FACTORY_ADDRESS + sync: false # from-env: PROJECT_FACTORY_ADDRESS - key: ERC1155_FACTORY_ADDRESS sync: false # from-env: ERC1155_FACTORY_ADDRESS - key: DELEGATABLE_NOTES_ADDRESS diff --git a/scripts/deployment-manifest.mjs b/scripts/deployment-manifest.mjs index 0669510cd..b6586a2ed 100644 --- a/scripts/deployment-manifest.mjs +++ b/scripts/deployment-manifest.mjs @@ -17,6 +17,7 @@ const LOGICAL_CONTRACTS = [ ['NudgePublications', 'NUDGE_PUBLICATIONS_CONTRACT_ADDRESS', 'START_BLOCK'], ['PublishedData', 'PUBLISHED_DATA_CONTRACT_ADDRESS', 'PUBLISHED_DATA_START_BLOCK'], ['AssuranceContractFactory', 'ASSURANCE_CONTRACT_FACTORY_ADDRESS', 'LAZYGIVING_START_BLOCK'], + ['ProjectFactory', 'PROJECT_FACTORY_ADDRESS', 'LAZYGIVING_START_BLOCK'], ['ERC1155Factory', 'ERC1155_FACTORY_ADDRESS', 'LAZYGIVING_START_BLOCK'], ['ContentRegistry', 'CONTENT_REGISTRY_ADDRESS', 'CONTENT_FUNDING_START_BLOCK'], ['ChannelRegistry', 'CHANNEL_REGISTRY_ADDRESS', 'CONTENT_FUNDING_START_BLOCK'], diff --git a/sdk/src/subsystems/lazy-giving/queries.created.test.ts b/sdk/src/subsystems/lazy-giving/queries.created.test.ts new file mode 100644 index 000000000..418bbe8fb --- /dev/null +++ b/sdk/src/subsystems/lazy-giving/queries.created.test.ts @@ -0,0 +1,119 @@ +import assert from 'assert'; +import { encodeAbiParameters, encodeEventTopics } from 'viem'; +import { ProjectFactoryAbi } from '../../abis.js'; +import { createSDKMachinery } from '../../machinery.js'; +import type { RawEventFromCache } from '../../utils/eventCacheClient.js'; +import { padAddressAsTopic } from '../../utils/eventCacheClient.js'; +import { getUserCreatedProjects } from './queries.js'; + +const FACTORY = '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' as const; +const CREATOR = '0x1111111111111111111111111111111111111111' as const; +const OTHER = '0x2222222222222222222222222222222222222222' as const; +const TOKEN = '0x3333333333333333333333333333333333333333' as const; +const PROJECT_A = '0x4444444444444444444444444444444444444444' as const; +const PROJECT_B = '0x5555555555555555555555555555555555555555' as const; +const CONDITION = '0x6666666666666666666666666666666666666666' as const; +const TX_HASH = '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' as const; + +function makeProjectCreatedEvent( + creator: `0x${string}`, + assuranceContract: `0x${string}`, + logIndex: number, +): RawEventFromCache { + const topics = encodeEventTopics({ + abi: ProjectFactoryAbi, + eventName: 'ProjectCreated', + args: { creator, token: TOKEN, assuranceContract }, + }); + return { + id: `${assuranceContract}-${logIndex}`, + contractAddress: FACTORY, + eventName: 'ProjectCreated', + blockNumber: '100', + blockTimestamp: '1700000000', + transactionHash: TX_HASH, + logIndex, + topic0: topics[0] ?? null, + topic1: (topics[1] ?? null) as string | null, + topic2: (topics[2] ?? null) as string | null, + topic3: (topics[3] ?? null) as string | null, + data: encodeAbiParameters([{ type: 'address' }], [CONDITION]), + }; +} + +describe('getUserCreatedProjects', () => { + const originalFetch = globalThis.fetch; + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + it('filters ProjectCreated by creator topic and returns unique assurance contracts', async () => { + const creatorEvents = [ + makeProjectCreatedEvent(CREATOR, PROJECT_A, 0), + makeProjectCreatedEvent(CREATOR, PROJECT_B, 1), + makeProjectCreatedEvent(CREATOR, PROJECT_A, 2), + ]; + globalThis.fetch = (async (input: string | URL | Request) => { + const url = new URL(typeof input === 'string' ? input : input instanceof URL ? input.href : input.url); + assert.strictEqual(url.searchParams.get('eventName'), 'ProjectCreated'); + assert.strictEqual(url.searchParams.get('topic1'), padAddressAsTopic(CREATOR)); + return new Response(JSON.stringify({ items: creatorEvents }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + }) as typeof fetch; + + const machinery = createSDKMachinery({ + ipfsConfig: { shouldUseMock: true }, + eventCacheUrl: 'http://localhost:42069', + contractAddresses: { + beliefs: '0x0000000000000000000000000000000000000000', + implications: '0x0000000000000000000000000000000000000000', + assuranceContractFactory: '0x0000000000000000000000000000000000000000', + erc1155Factory: '0x0000000000000000000000000000000000000000', + delegatableNotes: '0x0000000000000000000000000000000000000000', + noteIntent: '0x0000000000000000000000000000000000000000', + alignmentAttestations: '0x0000000000000000000000000000000000000000', + mutableRefUpdater: '0x0000000000000000000000000000000000000000', + trustRegistry: '0x0000000000000000000000000000000000000000', + }, + }); + + const addresses = await getUserCreatedProjects(machinery, CREATOR); + assert.deepStrictEqual(addresses, [PROJECT_A, PROJECT_B]); + }); + + it('does not include other creators when the cache honors topic1', async () => { + globalThis.fetch = (async (input: string | URL | Request) => { + const url = new URL(typeof input === 'string' ? input : input instanceof URL ? input.href : input.url); + const topic1 = url.searchParams.get('topic1'); + const items = topic1 === padAddressAsTopic(CREATOR) + ? [makeProjectCreatedEvent(CREATOR, PROJECT_A, 0)] + : [makeProjectCreatedEvent(OTHER, PROJECT_B, 0)]; + return new Response(JSON.stringify({ items }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + }) as typeof fetch; + + const machinery = createSDKMachinery({ + ipfsConfig: { shouldUseMock: true }, + eventCacheUrl: 'http://localhost:42069', + contractAddresses: { + beliefs: '0x0000000000000000000000000000000000000000', + implications: '0x0000000000000000000000000000000000000000', + assuranceContractFactory: '0x0000000000000000000000000000000000000000', + erc1155Factory: '0x0000000000000000000000000000000000000000', + delegatableNotes: '0x0000000000000000000000000000000000000000', + noteIntent: '0x0000000000000000000000000000000000000000', + alignmentAttestations: '0x0000000000000000000000000000000000000000', + mutableRefUpdater: '0x0000000000000000000000000000000000000000', + trustRegistry: '0x0000000000000000000000000000000000000000', + }, + }); + + const addresses = await getUserCreatedProjects(machinery, CREATOR); + assert.deepStrictEqual(addresses, [PROJECT_A]); + }); +}); diff --git a/sdk/src/subsystems/lazy-giving/queries.ts b/sdk/src/subsystems/lazy-giving/queries.ts index ad80be35c..7c12b00d9 100644 --- a/sdk/src/subsystems/lazy-giving/queries.ts +++ b/sdk/src/subsystems/lazy-giving/queries.ts @@ -19,9 +19,11 @@ import { fetchEvents, fetchLazyGivingProjectEvents, fetchAllBoughtEvents, + padAddressAsTopic, } from '../../utils/eventCacheClient.js'; import { decodeLazyGivingAssuranceContractCreatedEvent, + decodeProjectCreatedEvent, decodeCreatorContractCreatedEvent, decodeAssuranceContractInitializedEvent, decodeContractMetadataUpdatedEvent, @@ -407,6 +409,29 @@ export async function getProjectContributions( return foldContributionsFromEvents(boughtEvents, [], undefined, fundingCurrency).contributions; } +/** + * Get assurance-contract addresses of LazyGiving projects created by a wallet. + * + * Filters indexed `ProjectFactory.ProjectCreated` by creator (topic1). Does not + * walk `eth_getLogs` from block 0. + */ +export async function getUserCreatedProjects( + machinery: SDKMachinery, + userAddress: string +): Promise { + const rawEvents = await fetchEvents(machinery, { + eventName: 'ProjectCreated', + topic1: padAddressAsTopic(userAddress), + limit: 10000, + }); + const addresses = []; + for (const raw of rawEvents) { + const decoded = decodeProjectCreatedEvent(raw); + if (decoded) addresses.push(decoded.assuranceContract.toLowerCase()); + } + return Array.from(new Set(addresses)); +} + /** * Get all contributions made by a specific user across all projects. * diff --git a/sdk/src/utils/eventDecoder.test.ts b/sdk/src/utils/eventDecoder.test.ts index fb6dbcac6..e6e381927 100644 --- a/sdk/src/utils/eventDecoder.test.ts +++ b/sdk/src/utils/eventDecoder.test.ts @@ -5,6 +5,7 @@ import { AssuranceContractAbi, ImplicationsAbi, NudgePublicationsAbi, + ProjectFactoryAbi, } from '../abis.js'; import type { RawEventFromCache } from './eventCacheClient.js'; import { @@ -12,6 +13,7 @@ import { decodeContractMetadataUpdatedEvent, decodeImplicationRevokedEvent, decodeNudgesPublishedEvent, + decodeProjectCreatedEvent, decodeSuccessRevokedEvent, } from './eventDecoder.js'; import { fakeIpfsCidV1 } from './test-helpers.js'; @@ -92,6 +94,42 @@ describe('eventDecoder', () => { }); }); + describe('decodeProjectCreatedEvent', () => { + it('roundtrips ProjectCreated including indexed creator and assuranceContract', () => { + const creator = '0x1111111111111111111111111111111111111111' as const; + const token = '0x2222222222222222222222222222222222222222' as const; + const assuranceContract = '0x3333333333333333333333333333333333333333' as const; + const condition = '0x4444444444444444444444444444444444444444' as const; + const topics = encodeEventTopics({ + abi: ProjectFactoryAbi, + eventName: 'ProjectCreated', + args: { creator, token, assuranceContract }, + }) as readonly `0x${string}`[]; + const data = encodeAbiParameters([{ type: 'address' }], [condition]); + const raw: RawEventFromCache = { + id: 'pc-1', + contractAddress: CONTRACT_ADDR, + eventName: 'ProjectCreated', + blockNumber: '100', + blockTimestamp: '1700000000', + transactionHash: TX_HASH, + logIndex: 0, + topic0: topics[0] ?? null, + topic1: topics[1] ?? null, + topic2: topics[2] ?? null, + topic3: topics[3] ?? null, + data, + }; + + const decoded = decodeProjectCreatedEvent(raw); + assert.ok(decoded); + assert.strictEqual(decoded.creator.toLowerCase(), creator); + assert.strictEqual(decoded.token.toLowerCase(), token); + assert.strictEqual(decoded.assuranceContract.toLowerCase(), assuranceContract); + assert.strictEqual(decoded.condition.toLowerCase(), condition); + }); + }); + describe('decodeNudgesPublishedEvent', () => { it('roundtrips a NudgesPublished event', () => { const publicationCid = fakeIpfsCidV1('publication'); diff --git a/sdk/src/utils/eventDecoder.ts b/sdk/src/utils/eventDecoder.ts index 5ce4c429d..0f12b34e9 100644 --- a/sdk/src/utils/eventDecoder.ts +++ b/sdk/src/utils/eventDecoder.ts @@ -14,6 +14,7 @@ import { AlignmentAttestationsAbi, MutableRefUpdaterAbi, AssuranceContractFactoryAbi, + ProjectFactoryAbi, ContentRegistryAbi, ChannelRegistryAbi, ChannelEscrowAbi, @@ -36,6 +37,7 @@ const ABI_MAP: Record = { AlignmentAttestations: AlignmentAttestationsAbi, MutableRefUpdater: MutableRefUpdaterAbi, AssuranceContractFactory: AssuranceContractFactoryAbi, + ProjectFactory: ProjectFactoryAbi, ContentRegistry: ContentRegistryAbi, ChannelRegistry: ChannelRegistryAbi, ChannelEscrow: ChannelEscrowAbi, @@ -449,6 +451,35 @@ export function decodeLazyGivingAssuranceContractCreatedEvent( }; } +export function decodeProjectCreatedEvent( + rawEvent: RawEventFromCache +): { + creator: `0x${string}`; + token: `0x${string}`; + assuranceContract: `0x${string}`; + condition: `0x${string}`; + contractAddress: `0x${string}`; + blockNumber: bigint; + blockTimestamp: bigint; + transactionHash: `0x${string}`; + logIndex: number; +} | null { + if (rawEvent.eventName !== 'ProjectCreated') return null; + const args = decodeRawEventLog(rawEvent); + if (!args) return null; + return { + creator: args.creator as `0x${string}`, + token: args.token as `0x${string}`, + assuranceContract: args.assuranceContract as `0x${string}`, + condition: args.condition as `0x${string}`, + contractAddress: rawEvent.contractAddress as `0x${string}`, + blockNumber: BigInt(rawEvent.blockNumber), + blockTimestamp: BigInt(rawEvent.blockTimestamp), + transactionHash: rawEvent.transactionHash as `0x${string}`, + logIndex: rawEvent.logIndex, + }; +} + export function decodeAssuranceContractInitializedEvent( rawEvent: RawEventFromCache ): { diff --git a/specs/tech/subsystems/aligning/indexer.md b/specs/tech/subsystems/aligning/indexer.md index 75849805c..3f93b6463 100644 --- a/specs/tech/subsystems/aligning/indexer.md +++ b/specs/tech/subsystems/aligning/indexer.md @@ -10,7 +10,7 @@ All subsystems share a single thin event cache (one `events` table). The SDK fet ### LazyGiving -- **Project discovery:** Projects discovered from `LazyGivingAssuranceContractCreated` factory events. +- **Project discovery:** Projects discovered from `LazyGivingAssuranceContractCreated` factory events. Creator lookup uses indexed `ProjectFactory.ProjectCreated` (creator is topic1). - **Project state:** `foldProject()` processes `ERC1155Bought`, `ERC1155Sold`, `ContractMetadataUpdated` events per project contract. On-chain view functions provide current balance, threshold, deadline. - **Contributions/refunds:** `foldContributions()` and `foldRefunds()` reconstruct per-participant contribution history from events. - **Retroactive reimbursement:** folds process later donations, per-contributor claim state, withdrawals, and reimbursement forgone events. Legacy generic secondary-market folds are not part of the LazyGiving/Aligning product flow. From 20a498cdb0770f8eb4e0508ff02c5a0986befdec Mon Sep 17 00:00:00 2001 From: Adam Spitz Date: Thu, 20 Aug 2026 19:35:15 -0400 Subject: [PATCH 6/7] Removed an unnecessary todo. --- inbox.md | 4 ---- 1 file changed, 4 deletions(-) diff --git a/inbox.md b/inbox.md index 347985a38..e5ac44a60 100644 --- a/inbox.md +++ b/inbox.md @@ -27,10 +27,6 @@ Also, don't let any of the items get too long; usually there's a separate .md fi - Before deploying the CauseStarter alignment-trust bootstrap outside local Hardhat, run `node scripts/generate-wallets.mjs`, fund `ALIGNMENT_TRUST_BOOTSTRAP_ADDRESS`, install the worker's generated Render secret block, and add the configured denylist canary to its persistent disk. Never deploy the checked-in local Hardhat key; see the worker README runbook. -### Docs / UI copy - -- Decide whether to act on the fresh landing-copy positioning findings. The Civility grievance-first hero was reviewed and is fine; the verifier rubric was corrected so CSM’s recognition-register rule is not imposed on every vertical. Remaining findings are elsewhere: the umbrella Commonality landing still recruits generic end users despite the founder-first strategy, CSM front-loads the mediator toggle and uses “the other side’s bullshit,” Aligning repeats its main tradeoff several times, and Tally’s “Sign once, counted forever” headline presents a future goal as current capability. - ### Features that I'm realizing would make a big difference - Bridge-creator package is done; remaining work (CSM beat-agent stand-up, Civility-agent context source adapter, feeding signing outcomes into anchor reflection, and end-to-end rehearsal) is enumerated in [`bridge-creator-csm-next-steps.md`](workflow/bridge-creator-csm-next-steps.md). Mostly LLM-doable; the rehearsal pass needs your judgment. From 7a5ad4e0a782d29ce1df8772bc4ba136cf57432e Mon Sep 17 00:00:00 2001 From: Adam Spitz Date: Thu, 20 Aug 2026 20:55:18 -0400 Subject: [PATCH 7/7] Match only real git commit/push/merge in the agent branch guard. The old regex treated any later word "merge" (including a tool description or git merge-base) as a merge onto protected branches. Parse JSON properly, detect the actual git subcommand, and restore inbox items the cherry-pick had dropped. --- .claude/hooks/block-protected-branch.sh | 32 +++--- .claude/hooks/git-mutating-subcommand.py | 128 +++++++++++++++++++++++ inbox.md | 10 +- workflow/branching.md | 2 +- 4 files changed, 152 insertions(+), 20 deletions(-) create mode 100755 .claude/hooks/git-mutating-subcommand.py diff --git a/.claude/hooks/block-protected-branch.sh b/.claude/hooks/block-protected-branch.sh index 7a88e5d86..13868d71d 100755 --- a/.claude/hooks/block-protected-branch.sh +++ b/.claude/hooks/block-protected-branch.sh @@ -1,30 +1,28 @@ #!/usr/bin/env bash -# Claude Code PreToolUse hook (Bash): stop an agent from committing/pushing -# directly on master or dev. This is the "graceful" layer — the .husky git -# hooks are the real enforcement (and fire for any tool, not just Claude). +# PreToolUse hook (Bash / Grok run_terminal_command): stop an agent from +# committing/pushing/merging directly on master or dev. This is the "graceful" +# layer — the .husky git hooks are the real enforcement (and fire for any tool). # # Exit 2 blocks the tool call and feeds stderr back to the model so it can # self-correct by starting a feature branch. +set -euo pipefail + +HOOK_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +MATCHER="$HOOK_DIR/git-mutating-subcommand.py" + +if [ "${1:-}" = --self-test ]; then + exec python3 "$MATCHER" --self-test +fi + input=$(cat) -cmd=$(printf '%s' "$input" | sed -n 's/.*"command"[[:space:]]*:[[:space:]]*"\(.*\)/\1/p') -# Only care about git commit / push / merge as real subcommands. -# Pattern: git, optional intermediate tokens (e.g. -C path), then commit|push|merge -# as a whole token. The char after the subcommand must not be alnum/_/- so we do -# NOT match false friends: merge-base, merge-file, merge-tree, commit-tree. -# Example that must stay allowed: `git merge-base --is-ancestor origin/master origin/dev` -if ! printf '%s' "$cmd" | grep -Eq \ - '(^|[^[:alnum:]_/-])git[[:space:]]+(.+[[:space:]])?(commit|push|merge)([^[:alnum:]_-]|$)'; then +# Honor the same escape hatch as the git hooks (also recognized inside the matcher). +if ! printf '%s' "$input" | python3 "$MATCHER"; then exit 0 fi -# Honor the same escape hatch as the git hooks. -case "$cmd" in - *ALLOW_PROTECTED_COMMIT=1*) exit 0 ;; -esac - -branch=$(git -C "${CLAUDE_PROJECT_DIR:-.}" symbolic-ref --short HEAD 2>/dev/null) +branch=$(git -C "${CLAUDE_PROJECT_DIR:-.}" symbolic-ref --short HEAD 2>/dev/null || true) case "$branch" in master|dev) echo "Blocked: '$branch' is a protected branch. Do not commit/push/merge directly onto it." >&2 diff --git a/.claude/hooks/git-mutating-subcommand.py b/.claude/hooks/git-mutating-subcommand.py new file mode 100755 index 000000000..dae2c034c --- /dev/null +++ b/.claude/hooks/git-mutating-subcommand.py @@ -0,0 +1,128 @@ +#!/usr/bin/env python3 +"""Detect git commit/push/merge as real subcommands in a shell snippet. + +Used by block-protected-branch.sh. First non-option token after `git` must be +exactly commit, push, or merge — not merge-base, and not the word "merge" in a +log message or tool description. +""" +from __future__ import annotations + +import json +import re +import shlex +import sys + +MUTATING = {"commit", "push", "merge"} + + +def extract_command(raw: str) -> str: + raw = raw.strip() + if not raw: + return "" + try: + data = json.loads(raw) + except json.JSONDecodeError: + return raw + if not isinstance(data, dict): + return "" + cmd = data.get("command") + if isinstance(cmd, str) and cmd: + return cmd + tool_input = data.get("tool_input") + if isinstance(tool_input, dict): + inner = tool_input.get("command") + if isinstance(inner, str): + return inner + return "" + + +def _statements(cmd: str) -> list[str]: + return re.split(r"[;\n]|\|\||&&|\|", cmd) + + +def _first_git_subcommand(argv: list[str]) -> tuple[str | None, bool]: + i = 0 + while i < len(argv) and re.match(r"^[A-Za-z_][A-Za-z0-9_]*=", argv[i]): + i += 1 + if i >= len(argv): + return None, False + if not re.search(r"(^|/)git$", argv[i]): + return None, False + allow = any(re.match(r"ALLOW_PROTECTED_COMMIT=", a) for a in argv[: i + 1]) + i += 1 + while i < len(argv): + a = argv[i] + if a == "--": + i += 1 + break + if a in ("-C", "-c"): + i += 2 + continue + if a.startswith("-"): + i += 1 + continue + return a, allow + if i < len(argv): + return argv[i], allow + return None, allow + + +def is_mutating_git(cmd: str) -> bool: + for stmt in _statements(cmd): + stmt = stmt.strip() + if not stmt: + continue + try: + argv = shlex.split(stmt) + except ValueError: + argv = stmt.split() + sub, allow = _first_git_subcommand(argv) + if sub in MUTATING and not allow: + return True + return False + + +def self_test() -> int: + samples = [ + ("git log --oneline", False), + ("git merge-base --is-ancestor a b", False), + ("/usr/bin/git merge-base --is-ancestor a b", False), + ("git status", False), + ("git switch -c feature/x", False), + ("git merge other", True), + ("git commit -m msg", True), + ("git push origin HEAD", True), + ("git -C /tmp merge other", True), + ("git -C /tmp merge-base a b", False), + ('git log; echo "merge status"', False), + ("ALLOW_PROTECTED_COMMIT=1 git commit -m x", False), + ] + failed = 0 + for sample, expect in samples: + got = is_mutating_git(sample) + if got != expect: + print(f"FAIL {sample!r}: got {got}, want {expect}", file=sys.stderr) + failed += 1 + payload = json.dumps({ + "command": "git log --oneline", + "description": "Inspect serialize branch history and merge status", + }) + if is_mutating_git(extract_command(payload)): + print("FAIL json description containing 'merge' blocked git log", file=sys.stderr) + failed += 1 + if failed: + return 1 + print(f"{len(samples) + 1} checks passed") + return 0 + + +def main(argv: list[str]) -> int: + if argv[1:] == ["--self-test"]: + return self_test() + raw = sys.stdin.read() + cmd = extract_command(raw) + return 0 if is_mutating_git(cmd) else 1 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv)) diff --git a/inbox.md b/inbox.md index e5ac44a60..f933758e5 100644 --- a/inbox.md +++ b/inbox.md @@ -17,9 +17,11 @@ Also, don't let any of the items get too long; usually there's a separate .md fi ## Main list -### Tell — done after the fact +- **(Tell)** Combinator statements are specified and implemented: canonical `all`/`any` over sorted plank CIDs (no title/date), CauseStarter view-strip promote, implication attester structural gate for pairwise arrows only. Ordinary `createStatement` no longer defaults `createdDate` into extras. -- Indexed `ProjectFactory.ProjectCreated` in the event cache and switched CauseStarter’s “projects you created” list to `getUserCreatedProjects` (creator-filtered by topic1). No more `eth_getLogs` from block 0. Hosted indexer needs `PROJECT_FACTORY_ADDRESS` (added in `render.yaml`; also in the deployment-manifest builder). Existing stacks must reindex that contract to populate the new events. +- **(Tell)** Cause-board **Fully reimbursed** now means success-vouched *and* `outstandingUnreimbursedAmount === 0` (never-scouted successes omitted). It no longer reuses `AlignedProjectsList` with `statusFilterLock="succeeded"` (raised ≥ threshold). New SDK query: `getFullyReimbursedProjectsForCause`. + +- **(Tell)** Indexed `ProjectFactory.ProjectCreated` in the event cache and switched CauseStarter’s “projects you created” list to `getUserCreatedProjects` (creator-filtered by topic1). No more `eth_getLogs` from block 0. Hosted indexer needs `PROJECT_FACTORY_ADDRESS` (added in `render.yaml`; also in the deployment-manifest builder). Existing stacks must reindex that contract to populate the new events. ### Security/recoverability human actions @@ -27,6 +29,10 @@ Also, don't let any of the items get too long; usually there's a separate .md fi - Before deploying the CauseStarter alignment-trust bootstrap outside local Hardhat, run `node scripts/generate-wallets.mjs`, fund `ALIGNMENT_TRUST_BOOTSTRAP_ADDRESS`, install the worker's generated Render secret block, and add the configured denylist canary to its persistent disk. Never deploy the checked-in local Hardhat key; see the worker README runbook. +### Docs / UI copy + +- Decide whether to act on the fresh landing-copy positioning findings. The Civility grievance-first hero was reviewed and is fine; the verifier rubric was corrected so CSM’s recognition-register rule is not imposed on every vertical. Remaining findings are elsewhere: the umbrella Commonality landing still recruits generic end users despite the founder-first strategy, CSM front-loads the mediator toggle and uses “the other side’s bullshit,” Aligning repeats its main tradeoff several times, and Tally’s “Sign once, counted forever” headline presents a future goal as current capability. + ### Features that I'm realizing would make a big difference - Bridge-creator package is done; remaining work (CSM beat-agent stand-up, Civility-agent context source adapter, feeding signing outcomes into anchor reflection, and end-to-end rehearsal) is enumerated in [`bridge-creator-csm-next-steps.md`](workflow/bridge-creator-csm-next-steps.md). Mostly LLM-doable; the rehearsal pass needs your judgment. diff --git a/workflow/branching.md b/workflow/branching.md index d92fc33fe..8519d257a 100644 --- a/workflow/branching.md +++ b/workflow/branching.md @@ -123,7 +123,7 @@ is driving: | GitHub branch protection on `master` & `dev` | No direct pushes, no force-push/delete, PR required, conversations must resolve. `enforce_admins` is on, so it applies to you too. | No — server-side | | `.husky/pre-commit` guard | Refuses commits while `HEAD` is `master`/`dev` | `--no-verify` / escape hatch | | `.husky/pre-push` guard | Refuses pushing local `master`/`dev` | `--no-verify` / escape hatch | -| `.claude/hooks/block-protected-branch.sh` | Makes *Claude Code* self-correct onto a feature branch gracefully instead of erroring | Claude-only sugar | +| `.claude/hooks/block-protected-branch.sh` | Makes Claude Code / Grok self-correct onto a feature branch instead of erroring. Matches `git commit` / `git push` / `git merge` as subcommands only (not `merge-base`, not the word "merge" in a description). | Agent sugar; husky still enforces | Escape hatch for a genuine hotfix commit (still can't push to protected branch on GitHub): `ALLOW_PROTECTED_COMMIT=1 git commit ...`