Skip to content

feat: add ASCA, goal savings and collective fund stack types - #150

Open
RonTuretzky wants to merge 35 commits into
developmentfrom
feat/new-stack-types
Open

feat: add ASCA, goal savings and collective fund stack types#150
RonTuretzky wants to merge 35 commits into
developmentfrom
feat/new-stack-types

Conversation

@RonTuretzky

@RonTuretzky RonTuretzky commented Jul 8, 2026

Copy link
Copy Markdown

Summary

Adds frontend support for three new stack types, backed by the new contracts in saving-circles PR #184:

Type Contract Routes
ASCA — flexible savings + credit lines AccumulatingSavingCircles /new/asca, /ascas/[id]
Goal savings — save toward a target GoalSavingCircles /new/goal, /goals/[id]
Collective fund — save together, spend together (white-label) CollectiveFundCircles /new/collective, /funds/[id]

Everything is behind feature flags (asca, goalSavings, collectiveFund in NEXT_PUBLIC_FEATURES): with flags off the app renders exactly as before (verified by building with the new env vars absent and flags off).

What's included

  • Type picker on /new — four cards; ROSCA keeps the exact existing tutorial → form flow, the three new cards are feature-gated.
  • Creation wizards per type (react-hook-form + zod, mirroring the existing form): ASCA (borrow limit % ≤ 100, interest %/period, period length, repayment periods), Goal (target amount, deadline, optional beneficiary vs reclaim mode), Collective (on-chain fund name, voting period, approval threshold). Each parses its creation event for the id and generates the standard EIP-712 StacksInvite links (same domain, per-contract verifyingContract).
  • Detail pages: ASCA (pool stats, your savings/withdrawable/pending interest, credit line, loan + due countdown, deposit/withdraw/borrow/repay/claim, liquidate overdue members, owner deactivate); Goal (progress bar, state chip, deadline countdown by block time, deposit/withdraw/release/cancel per state rules); Collective (on-chain name — the fund IS the brand, pool balance, shares + previewWithdraw, proposals list with vote/execute, new-proposal form, deposit/donate/rage-quit).
  • Join flow: /stacks/join handles type=asca|goal|collective (per-type contract, display card, redirect).
  • Home + account: feature-gated sections listing your goals/funds/ASCAs via the contracts' reverse indexes; ROSCA carousel untouched.
  • Plumbing: src/lib/stack-types.ts discriminator; Zod env vars (optional, zero-address defaults) + constants; hand-mirrored ABIs; per-contract tx hooks through the useSimulateAndSponsorTx pipeline; full contract-error maps; modal variants + presenter branches; make update-env picks up the new deployment keys.

Supabase

  • New migration add_stack_type: stack_type text not null default 'rosca' on stacks_metadata (idempotent). Not pushed to any environment — run pnpm db:push:<env> when deploying.
  • New types use prefixed metadata ids (asca:<id>, goal:<id>, collective:<id>) so id spaces across contracts can't collide; bare ids remain ROSCA. /api/stacks/metadata and /api/stacks/invite accept the optional stackType.

Deployment notes

  • New env vars (all optional; features stay hidden without them): NEXT_PUBLIC_ASCA_CONTRACT_ADDRESS, NEXT_PUBLIC_GOAL_SAVINGS_CONTRACT_ADDRESS, NEXT_PUBLIC_COLLECTIVE_FUND_CONTRACT_ADDRESS.
  • Flip features per deployment via NEXT_PUBLIC_FEATURES, e.g. {"asca":{"enabled":true},"goalSavings":{"enabled":true},"collectiveFund":{"enabled":true}}.
  • The contracts/lib/saving-circles submodule bump to include the new contracts is deliberately left out of this PR — bump it once saving-circles PR feat(yield): draft UI to show + claim saving-circle yield #184 merges to dev.

Gates

pnpm lint (0 errors), pnpm build (all routes compile, strict TS), pnpm format:check — all green on every commit; husky lint-staged passed throughout.

Verified end to end on-chain

Added e2e/onchain-journey/ — a test-only harness that drives this UI (no test hooks, no mocked contracts) against a local anvil fork of Gnosis, and asserts every step with an independent viem read. A green run means the app's wiring produced the on-chain effect, not that the script did. The assertion reads load the app's own ABIs straight out of src/lib/abis/*.ts, so a drifted ABI fails the run. Each journey opens two browser sessions (two real wallets) so invite/join/vote are genuinely a second party, and a third wallet that is never a member is used as beneficiary/recipient.

What the journeys assert

  • ASCAcreate (token, borrow limit, interest rate, repayment periods, period length read back from getFund and compared exactly; a deliberately fractional 2.5% is asserted as 250 bps on chain and as "2.5% per period" rendered back in the UI) → invite + join from wallet Add local SavingCircles deployment script #2 (isMember, usedNonces, roster) → deposits from both (savings, totalSavings, poolCash, creditLineOf, and the exact ERC-20 spend) → borrow (principal, Borrowed.dueDate == borrow time + N periods, tokens actually moved, pool cash debited) → time warp + repay (principal 0, Repaid.interestPaid > 0, accInterestPerShare grew, interest split across both savers) → claimInterest (wallet receives exactly pendingInterestOf) → withdraw (savings back to 0, tokens returned).
  • Goal savingscreate with a beneficiary (getGoal: target, the deadline picked in the form, beneficiary) → invite + join → deposits (contributions, totalDeposited, goalReached still false below target) → crossing the target (goalReached latch, GoalReached, state Funded) → release (beneficiary receives the whole pot including overshoot, state Released, goal emptied) → a second goal in reclaim mode (no beneficiary): deposit, then withdraw refunds the contribution exactly.
  • Collective fundcreate with an on-chain name and approval threshold (getFund: name, voting period, approvalThresholdBps) → invite + join (isMember, memberIndex, roster) → deposits (sharesOf 1:1, totalShares, poolBalance) → propose (snapshotted electorate and requiredYes as the ceil of the threshold, proposer auto-vote, state Active) → vote from member Add local SavingCircles deployment script #2 (yesVotes == 2, state Passed, and the card's "2 yes / 2 required" compared to the chain) → execute (recipient paid exactly, pool debited) → rage-quit withdraw (payout equals floor(shares * poolBalance / totalShares) exactly — the amounts are chosen so the division has a remainder).

No app bugs were found, and no assertion was weakened to get green. Classes of bug these would have caught: wrong arg order/encoding in create, wrong decimals, percent→bps rounding, a mis-wired button (each action is opened from its own panel and confirmed in its modal, and the on-chain effect is required), and a broken read (previewWithdraw is compared to the floor formula).

GIFs — recorded straight from these runs and documented in docs/STACK_TYPES.md, which also writes up all four stack types (routes, contract, env var, feature flag, user flow) and is linked from the README and docs/ARCHITECTURE.md:

ASCA create → invite → deposit → borrow → repay → claim interest → withdraw
Goal savings create with beneficiary → deposit → cross the target → release → reclaim-mode goal + refund
Collective fund create → deposit for shares → propose → vote → execute → rage-quit withdraw

How to run it

cd e2e/onchain-journey
npm install                        # playwright + viem, isolated from the app's pnpm workspace
npx playwright install chromium
npm test                           # = bash run.sh

run.sh reuses an anvil on :8545 and a dev server on :3001 if they are up (and starts them if not), checks the contract addresses in .env.local have code, funds the test wallets with gas and BREAD, runs the three journeys, and exits non-zero on any failed assertion. It never resets chain state — every journey reads nextId and asserts against the id it creates, so it is repeatable against a dirty chain.

Why the app needed one small change

Privy's embedded wallet cannot be driven from localhost: Turnstile CAPTCHA is enabled and localhost isn't in the app's allowed_domains, so an automated login is impossible — and every write in the app funnels through useSponsoredTx → Privy's useSendTransaction. Rather than mock anything, the app now has a local-only E2E wallet mode (src/lib/e2e.ts) that switches @breadcoop/ui's authProvider to "general" — a mode the design system already ships and supports, which derives the connected user from wagmi — and routes signing through wagmi's injected connector.

It is triple-gated and can never be true in a deployed environment:

  1. NEXT_PUBLIC_NODE_ENV === "local" — never true on a deployed build
  2. NEXT_PUBLIC_E2E_WALLET === "true" — explicit opt-in
  3. NEXT_PUBLIC_CHAIN_ID === 31337 — anvil only, never a real network

The app never holds a private key: the harness injects a pure EIP-1193 proxy (announced over EIP-6963, with a wagmi store preseed so there is no connect modal); reads go to the fork RPC and signing is delegated to the harness's Node process. run.sh ends with check-bundle.sh, a hard grep gate that fails if any signing key ever appears in .next. The signers are the public anvil dev accounts, funded only on the local fork.

Two known, deliberate limitations of the local mode — both off-chain only, neither affecting any assertion: POST /api/stacks/metadata 400s because the route requires a privyUserId and there is no Privy user in this mode (the call is fire-and-forget, so names/invite links just aren't persisted to Supabase during a run); and /api/shorten 500s locally with a dummy SPOO_TOKEN, so the modal falls back to long invite URLs. Loosening the metadata route's identity model is flagged rather than done — it needs an explicit decision on how a bare wallet address maps to a users row.

@netlify

netlify Bot commented Jul 8, 2026

Copy link
Copy Markdown

Deploy Preview for app-stacks ready!

Name Link
🔨 Latest commit fe0fbad
🔍 Latest deploy log https://app.netlify.com/projects/app-stacks/deploys/6a78e911f18bc50008fb7ecf
😎 Deploy Preview https://deploy-preview-150--app-stacks.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

RonTuretzky and others added 5 commits July 8, 2026 13:03
Privy's embedded wallet cannot be driven from localhost (Turnstile CAPTCHA
plus a domain allowlist), so nothing that writes to a contract could be
exercised end-to-end against anvil.

Add a local-only mode that swaps the signing path to an injected EIP-1193
wallet over wagmi, using the Bread UI kit's own "general" auth provider.
It is triple-gated in src/lib/e2e.ts (NEXT_PUBLIC_NODE_ENV=local AND
NEXT_PUBLIC_E2E_WALLET=true AND chain 31337), so it cannot switch on in a
deployed environment. No key, mnemonic or signer lives in the app: it only
talks to whatever provider the test harness injects.

- src/lib/e2e.ts: the single gate, with the flag optional in the Zod schema
- providers: authProvider="general" and wagmi's own config/provider, since
  the Privy wagmi adapter strips non-mock connectors and EIP-6963 discovery
- use-sponsored-tx and the new use-app-sign-typed-data wrap the Privy hooks
  so feature code keeps one call signature either way

Verified by driving a real ASCA creation in a headless browser: the fund is
created on chain by the injected wallet and its invite links are signed via
wagmi typed-data. With the flag off the app falls back to Privy sign-in.
Adds the shared plumbing for driving the real UI against the local anvil
fork with an injected key-backed wallet:

- lib.cjs: addresses from the app's own .env.local, ABIs loaded straight
  out of src/lib/abis so a drifted ABI fails the run, per-wallet viem
  signers whose keys never leave Node, anvil cheatcodes and the
  independent reads every assertion is made against
- inject.cjs: the EIP-1193 shim plus EIP-6963 announce and the wagmi
  store preseed that makes the app reconnect with zero clicks
- ui.cjs: recorded 1280x800 browser sessions (one per wallet) and the
  helpers for driving forms, panels and the shared tx modals at a
  watchable pace
- setup.cjs: pre-flight on chain id, deployed contracts and the E2E gate,
  then funds both test wallets
- check-bundle.sh: hard gate that fails if a signing key ever reaches the
  built app
Three end-to-end journeys, each driving the full member lifecycle through
the real UI with two independent wallets and asserting every step with an
on-chain read:

- asca: create via the /new type picker, invite and join, deposits from
  both members, borrow, repay after a time warp with the interest split
  across savers, claim interest, withdraw
- goal: create with a beneficiary, invite and join, deposits crossing the
  target (goalReached latch and event), release the whole pot, then a
  second goal in reclaim mode refunded via withdraw
- collective: create with an on-chain name and threshold, invite and
  join, deposits, propose with a snapshotted electorate, vote, execute,
  and a rage-quit withdraw that must equal the contract's floored
  pro-rata payout exactly

run.sh reuses or starts anvil and the dev server, runs the pre-flight,
runs all three, then the bundle key gate; it exits non-zero on any failed
assertion and is re-runnable because each journey creates new ids.
next dev compiles a route on first request, so the 5s probe could time out
against a server that was in fact up and the runner would spawn a second
one on the next free port. Give the probe room and require a 2xx/3xx.
Adds docs/STACK_TYPES.md: what each of the four stack types is, its create
and detail routes, its contract and env var, and its user flow, plus a GIF
per new type recorded straight from the on-chain journey tests driving the
real UI against an anvil fork of Gnosis (dead time cut, 900px/8fps, two-pass
palette). Links it from README.md and docs/ARCHITECTURE.md, and finishes the
harness README with prerequisites, the single-journey commands, the GIF
recipe and a CI note.
simulateContract fell back to the wallet connector chain, so creating a stack
while the wallet sat on another network failed with a zero-data error instead
of a real revert. Pin the chain the way the shared tx hook already does.
waitForTransactionReceipt inherited the wallet connector chain, so with a
wallet on another network the app polled the wrong chain for the hash and the
pending modal never resolved. Pin the chain on every receipt wait, including
the shared hook every action uses.
usePublicClient and useWatchContractEvent inherited the wallet connector
chain, so generating invite links (and the deposit/claim event reads) queried
the wrong network and returned no data. Pin the chain on every public client.
The shared contract descriptors for asca, goal savings and collective funds
did not all carry chainId, so reads spreading them followed the wallet
connector chain and returned nothing when it differed from the deployment.
Every read, public client and wagmi action now targets the configured chain.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants