feat: add ASCA, goal savings and collective fund stack types - #150
Open
RonTuretzky wants to merge 35 commits into
Open
feat: add ASCA, goal savings and collective fund stack types#150RonTuretzky wants to merge 35 commits into
RonTuretzky wants to merge 35 commits into
Conversation
✅ Deploy Preview for app-stacks ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds frontend support for three new stack types, backed by the new contracts in saving-circles PR #184:
AccumulatingSavingCircles/new/asca,/ascas/[id]GoalSavingCircles/new/goal,/goals/[id]CollectiveFundCircles/new/collective,/funds/[id]Everything is behind feature flags (
asca,goalSavings,collectiveFundinNEXT_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
/new— four cards; ROSCA keeps the exact existing tutorial → form flow, the three new cards are feature-gated.StacksInvitelinks (same domain, per-contractverifyingContract).previewWithdraw, proposals list with vote/execute, new-proposal form, deposit/donate/rage-quit)./stacks/joinhandlestype=asca|goal|collective(per-type contract, display card, redirect).src/lib/stack-types.tsdiscriminator; Zod env vars (optional, zero-address defaults) + constants; hand-mirrored ABIs; per-contract tx hooks through theuseSimulateAndSponsorTxpipeline; full contract-error maps; modal variants + presenter branches;make update-envpicks up the new deployment keys.Supabase
add_stack_type:stack_type text not null default 'rosca'onstacks_metadata(idempotent). Not pushed to any environment — runpnpm db:push:<env>when deploying.asca:<id>,goal:<id>,collective:<id>) so id spaces across contracts can't collide; bare ids remain ROSCA./api/stacks/metadataand/api/stacks/inviteaccept the optionalstackType.Deployment notes
NEXT_PUBLIC_ASCA_CONTRACT_ADDRESS,NEXT_PUBLIC_GOAL_SAVINGS_CONTRACT_ADDRESS,NEXT_PUBLIC_COLLECTIVE_FUND_CONTRACT_ADDRESS.NEXT_PUBLIC_FEATURES, e.g.{"asca":{"enabled":true},"goalSavings":{"enabled":true},"collectiveFund":{"enabled":true}}.contracts/lib/saving-circlessubmodule 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 todev.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 ofsrc/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
create(token, borrow limit, interest rate, repayment periods, period length read back fromgetFundand compared exactly; a deliberately fractional 2.5% is asserted as250bps 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,accInterestPerSharegrew, interest split across both savers) →claimInterest(wallet receives exactlypendingInterestOf) →withdraw(savings back to 0, tokens returned).createwith a beneficiary (getGoal: target, the deadline picked in the form, beneficiary) → invite + join → deposits (contributions,totalDeposited,goalReachedstill false below target) → crossing the target (goalReachedlatch,GoalReached, stateFunded) →release(beneficiary receives the whole pot including overshoot, stateReleased, goal emptied) → a second goal in reclaim mode (no beneficiary): deposit, thenwithdrawrefunds the contribution exactly.createwith an on-chain name and approval threshold (getFund: name, voting period,approvalThresholdBps) → invite + join (isMember,memberIndex, roster) → deposits (sharesOf1:1,totalShares,poolBalance) →propose(snapshottedelectorateandrequiredYesas the ceil of the threshold, proposer auto-vote, stateActive) →votefrom member Add local SavingCircles deployment script #2 (yesVotes == 2, statePassed, and the card's "2 yes / 2 required" compared to the chain) →execute(recipient paid exactly, pool debited) → rage-quitwithdraw(payout equalsfloor(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 (previewWithdrawis 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 anddocs/ARCHITECTURE.md:How to run it
run.shreuses an anvil on:8545and a dev server on:3001if they are up (and starts them if not), checks the contract addresses in.env.localhave 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 readsnextIdand 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 throughuseSponsoredTx→ Privy'suseSendTransaction. Rather than mock anything, the app now has a local-only E2E wallet mode (src/lib/e2e.ts) that switches@breadcoop/ui'sauthProviderto"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:
NEXT_PUBLIC_NODE_ENV === "local"— never true on a deployed buildNEXT_PUBLIC_E2E_WALLET === "true"— explicit opt-inNEXT_PUBLIC_CHAIN_ID === 31337— anvil only, never a real networkThe 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.shends withcheck-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/metadata400s because the route requires aprivyUserIdand 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/shorten500s locally with a dummySPOO_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 ausersrow.