feat: general invite link per stack + remove member - #161
Conversation
…nk per stack - Stack creation modal now shows ONE shareable invite link instead of N-1 individual links - New /api/stacks/invite/claim endpoint serves available nonces to general link visitors - Join page handles general links (no nonce/signature in URL) by claiming from API - Added "Remove" button beside each member on the stack page (owner only, pre-launch) - Remove triggers decommission with a confirmation warning modal - Updated UX copy to highlight link sharing and member management before launch Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
❌ Deploy Preview for app-stacks failed. Why did it fail? →
|
The stack page built the link with empty duration= and deposit= params, so invite-details rendered a blank term and computed $0.00 for both the deposit amount and the stack goal (+"" coerces to 0). Both values are already on the circle, so format them the way the creation modal does.
The warning is still accurate for the legacy per-member links, which are the only ones that dead-end when their nonce is spent. General links claim a nonce from the pool, so it stays hidden for those.
franrolotti
left a comment
There was a problem hiding this comment.
Functionally this works — I traced the general-link path end to end and the claim → redeemInvite → PATCH /api/stacks/invite cycle is sound. Happy to approve once the Remove button is dealt with.
I pushed three commits to this branch: a merge with development plus two small fixes (details at the bottom).
The Remove button can't work yet — suggest dropping it from this PR.
remove-member-warning.tsx:25 calls decommission(circleId), but decommission is onlyActive, and isActive is only set inside start(). The contract reports NotStarted exactly when !isActive, which is what "pending-start" resolves to in get-user-circle-status.ts:122 — the same condition the button is gated on (members-info.tsx:135). So it renders if and only if the transaction reverts with NotActive. Past that guard it would still fail _isDecommissionable, which needs currentRound > 0 and a missed deposit window; decommission unwinds a stalled live circle and refunds deposits, it was never a pre-launch cancel.
There's no function on the deployed implementation that removes a member. saving-circles#feat/add-members adds removeMember(id, member) with exactly the right guards (pre-start only, owner or the member themselves, owner not removable), but it needs a proxy upgrade. I'd cut the button, modal, and REMOVE_MEMBER_WARNING type here and bring them back in a follow-up once that lands — it's all subtractive, and the general-link half is good to ship on its own.
Claim/redeem race. claim/route.ts:31 returns the first used: false link but doesn't reserve it; used is only written by the PATCH after a successful redeem. Two people claiming at once get the same nonce and the second redeemInvite reverts InviteAlreadyUsed. The retry path recovers, so it's a rough edge rather than a break — but someone who claims and walks away leaves that nonce served to everyone until another visitor actually redeems it. Marking it used at claim time trades this for a worse bug (abandoned claims burn slots permanently), so the real fix is probably a reserved_at column with an expiry. Fine to defer, worth an issue.
Invites are still bearer tokens. /api/stacks/invite/claim returns a valid nonce + owner signature to anyone who passes a circleId, with no auth — and circle ids are sequential. Nothing is broken by this, but combined with a publicly shareable link it means anyone who guesses an id can take a slot without ever receiving the invite. The signature covers Invite(id, nonce) only, so it can't be bound to a wallet without the same contract upgrade as above. Worth tracking together with the removeMember work rather than fixing here.
What I pushed
7c6d0f1— merge withdevelopment, which the branch needed. One adjacency conflict in the imports ofmembers-info.tsx(ff63cefdeletedimport Link, this PR added two imports next to it); the alias/ENS refactor auto-merged cleanly with yourmemberDisplayNamechange.f7087b0—members.tsx:101built the link with emptyduration=&deposit=, so the invitee's preview showed a blank term and$0.00for both the deposit and the stack goal (+""is0). The creation modal passes real values, so the same stack previewed differently depending on which link was shared. Both values are already oncircle.3e81aaa— the "this invite can only be accepted once" alert was dropped for all links; it's still accurate for the legacy per-member ones, so it's back behind!isGeneralLink.
Revert any of them if you disagree.
The eslint config treats prettier/prettier as an error, so the unwrapped call failed `next build` on the deploy preview.
The prior general-link work still pre-signed a pool of N-1 EIP-712 invites per stack (up to 24 wallet prompts) and served them one-at-a-time through a claim endpoint. Replace it with a single circleId-only invite link: visitors request to join with no signature and no on-chain action, and the owner reviews requests and accepts them via the contract's new addMembers (bulk-capable, no signature) or dismisses them. removeMember replaces the old decommission-the-whole-stack workaround for removing a single pre-launch member. - Bump the saving-circles submodule to pick up addMembers/removeMember and add both to the frontend ABI. - New join_requests table (service-role only) and /api/stacks/join-request (request/list/decide) and /api/stacks/member (cleanup on removal) routes; drop the old pool-claim endpoint and stacks_metadata.invite_links. - Stack creation, the join page, and the owner's member list are reworked around the new flow, including bulk-accept for multiple pending requests in a single addMembers transaction. - Fix: invalidate wagmi's read caches after removeMember succeeds so the member list actually updates instead of only refreshing the server-rendered page.
…al split The Makefile now persists Anvil state across restarts and separates "just start the dev server" from "wipe off-chain data, redeploy, and start" — bring the README's setup and workflow instructions in line.
…ionSettings Picks up the self-authorized member and automation-settings migration functions the wallet-migration flow calls.
Temporary: points at file:../bread-ui-kit so the wallet-migration flow can pick up the connected-user EOA fallback fix before it's published. Revert to a published @breadcoop/ui version once bread-ui-kit's feat/connected-user-eoa-fallback branch lands and ships a release.
…ount overrides Adds the two new contract functions to the frontend ABIs, and lets useSavingCirclesTx/useAutomaticSavingCirclesTx send a tx from an explicit account (e.g. an embedded wallet acting as msg.sender) instead of always defaulting to the currently connected wallet.
Adds hooks to detect a genuinely linked external wallet (as opposed to the embedded wallet wagmi auto-connects) and to prefer it as the circle member address once it's actually on-chain, so claims and stack pages keep working through a migration. claim-button now calls withdrawFor with the effective member instead of a plain withdraw.
Adds transferred_to_wallet_at, migrated_stacks_at, and a last_migration_result jsonb column (externalAddress/failed/attemptedAt) on users, plus hooks and a PATCH /api/user path to read and write them. last_migration_result records which parts of a migration attempt failed (e.g. "circle:3", "circle:5:automation", "transfer:funds") so a partial failure can be retried on a later visit instead of silently looking done.
…allet users Surfaces a single banner + modal for users who linked an external wallet but still have stacks and/or funds sitting in their old Privy-created embedded wallet. On confirm it migrates each circle's membership (migrateMember) and automation opt-ins (migrateAutomationSettings), then sweeps any BREAD/xDAI — all sponsored from the embedded wallet with no wallet-UI prompts. Supabase's wallet_address only flips to the linked wallet once something has actually moved on-chain, so alias/dashboard lookups keep resolving to the old address until then. Also reopens for any circle whose core membership moved but whose automation settings didn't, using last_migration_result to find them.
Was temporarily set to "all-users" to create legacy embedded-wallet test accounts for exercising the migration flow.
Summary
/api/stacks/invite/claimendpoint.Changed files
src/components/modal/modals/stack-result.tsx— Single invite link generation + updated copysrc/app/api/stacks/invite/claim/route.ts— New API to claim available nonce for general linkssrc/app/stacks/join/_components/accept-invite.tsx— Handles general links without nonce/signaturesrc/app/stacks/join/page.tsx— Detects general vs direct invite linkssrc/app/stacks/[id]/_components/members-info.tsx— Remove button per membersrc/app/stacks/[id]/_components/members.tsx— General invite link display + isOwner propsrc/components/modal/modals/remove-member-warning.tsx— Confirmation modal for removalsrc/components/modal/context.tsx+presenter.tsx— REMOVE_MEMBER_WARNING modal typeTest plan
🤖 Generated with Claude Code