Skip to content

feat: batch deposit to multiple pools from the dashboard - #232

Open
Fury03 wants to merge 3 commits into
JointSave-org:mainfrom
Fury03:feat/batch-deposit-operations
Open

feat: batch deposit to multiple pools from the dashboard#232
Fury03 wants to merge 3 commits into
JointSave-org:mainfrom
Fury03:feat/batch-deposit-operations

Conversation

@Fury03

@Fury03 Fury03 commented Aug 18, 2026

Copy link
Copy Markdown

Closes #214

Members of several rotational pools had to open each pool page and sign a separate deposit. This adds a dashboard-level batch deposit: one place to see everything owed this round, pick what to pay, and run it all in one go.

The design decision behind this PR

The issue asked for "a single Stellar transaction with multiple invoke_contract operations". That isn't achievable on Soroban: a transaction may carry exactly one InvokeHostFunction operation — two contract calls in one transaction is rejected as malformed. Multi-operation batching is a classic-Stellar (payment-style) capability that does not extend to contract invocations. Without an on-chain batching contract (explicitly out of scope), N pool deposits are necessarily N transactions.

That constraint resolves a contradiction in the acceptance criteria rather than creating one. The issue asked for both "if one operation fails the entire transaction fails (atomicity)" and "retry only the failed pools" — these cannot both hold under one atomic transaction. Under N independent transactions they both make sense, and that is what's implemented: each deposit succeeds or fails on its own, confirmed deposits stay confirmed, and retry re-runs only the failures.

Signing is serialized through the app's existing lib/tx-queue.ts, so the wallet shows one prompt at a time instead of N racing popups.

What's in it

File
lib/batch-deposit.ts Pure logic — selection totals, urgency banding, batching, progress derivation
hooks/useBatchDeposit.ts getPoolsRequiringDeposit / buildBatchDepositTx / submitBatchDeposit / retryFailed, with per-pool status
components/dashboard/batch-deposit-panel.tsx Panel + selection dialog on the "My Groups" tab
components/dashboard/batch-deposit-progress.tsx Progress bar + per-pool status indicators
app/api/pools/route.ts New member= branch — every pool a wallet belongs to (created or joined)
hooks/useJointSaveContracts.ts submitTx exported as submitContractTx with an onPhase callback

The submitContractTx change is a rename + export + optional onPhase param, so the batch UI reuses the existing simulate → assemble → sign → send → poll pipeline instead of duplicating it. It also drops a kit parameter that was accepted but never used. All 18 existing call sites were updated; behaviour is unchanged.

Acceptance criteria

Criterion Where
Dashboard shows a "Batch Deposit" button when pools require deposits batch-deposit-panel.tsx, rendered from my-groups.tsx
Pool selection dialog lists all pools requiring deposits with correct amounts dialog list, test "lists every pool requiring a deposit…"
Total deposit amount calculated correctly across selected pools summarizeSelection / formatBatchSummary, test "totals the selection…"
Batch transaction built with correct operations for each selected pool test decodes each built envelope and asserts deposit(member) on the right contract id
Single wallet signature for the entire batch Reframed — not protocol-possible for N Soroban transactions. Signing goes through the tx-queue so prompts are serialized, one at a time, never concurrent. Flagged in the issue thread before implementation.
Progress indicator shows per-pool status during submission batch-deposit-progress.tsx — pending / signing / submitted / confirmed / failed
Failed batches clearly indicate which pools failed and why per-row error text, test "keeps successful deposits and surfaces why the failed one failed"
Retry mechanism allows re-attempting only failed pools retryFailed(), test "retries only the failed pools" asserts exactly one extra transaction
Batch split into multiple transactions when > 15 pools selected chunk(…, MAX_TX_PER_BATCH) + split notice, test "splits a selection larger than 15 pools…"
Batch deposit panel hidden when no pools require deposits test "stays hidden when no pool requires a deposit" + E2E
Mobile: fully responsive stacked layouts at sm: breakpoints; E2E asserts no horizontal overflow at 390px

Pools that are paused, inactive, or that the wallet isn't actually an on-chain member of are excluded from the scan — a deposit to any of those is a guaranteed on-chain failure.

Verification

pnpm test:unit        158 passed  (22 new, lib/batch-deposit.test.ts)
pnpm test:components  106 passed / 17 files  (10 new, __tests__/batch-deposit.test.tsx)
npx next build        compiled successfully
tsc --noEmit          37 errors — identical to the count on main, none in touched files
eslint + prettier     clean on every changed file

The component tests drive the real BatchDepositPanel through the real useBatchDeposit hook — only /api/pools and the contract layer (the repo's existing global mock) are stubbed. The transaction-building test decodes the actual built envelopes and asserts each one carries a single invokeHostFunction operation calling deposit(member) on that pool's contract.

e2e/batch-deposit.spec.ts covers the full browser flow, the hidden state, and a mobile viewport. It could not be executed locally — this environment is missing chromium's shared libraries (libnspr4.so) and has no network access to install them, so no Playwright spec runs here, including the repo's existing navigation.spec.ts. It will run in CI.

Members of several rotational pools had to visit each pool page and sign a
separate deposit. This adds a dashboard-level batch deposit: one place to see
everything owed this round, select what to pay, and run it all in one go.

Design note — Soroban permits exactly one InvokeHostFunction operation per
transaction, so N pool deposits cannot be fused into one atomic multi-operation
transaction; they are necessarily N independent transactions. That resolves the
tension in the issue between "atomic, all-or-nothing" and "retry only the failed
pools": each deposit now succeeds or fails on its own, confirmed deposits stay
confirmed, and only the failures are re-run. Signing is serialized through the
existing tx-queue so the wallet shows one prompt at a time.

- lib/batch-deposit.ts: selection totals, urgency banding, batching, progress
- hooks/useBatchDeposit.ts: getPoolsRequiringDeposit / buildBatchDepositTx /
  submitBatchDeposit / retryFailed, with per-pool status reporting
- components/dashboard/batch-deposit-panel.tsx: panel + selection dialog,
  select-all/deselect-all, live total, hidden when nothing is owed
- components/dashboard/batch-deposit-progress.tsx: progress bar and per-pool
  pending/signing/submitted/confirmed/failed indicators
- api/pools: `member=` returns every pool a wallet belongs to
- useJointSaveContracts: submitTx exported as submitContractTx with an onPhase
  callback so the batch UI reuses the submit pipeline instead of duplicating it

Transactions are queued in batches of 15 and the split is surfaced in the UI.

Tests: 22 unit tests for the pure logic, 10 component tests driving the real
panel through the real hook (asserting the built transactions invoke
deposit(member) on the right contract), and an E2E spec covering the flow,
the hidden state and a mobile viewport.

Closes JointSave-org#214
@Sendi0011

Copy link
Copy Markdown
Contributor

@Fury03 kindly fix failing checks so i can review

@Sendi0011 Sendi0011 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review — Request Changes

The batch deposit feature is excellently designed. The separation of pure logic (lib/batch-deposit.ts) from the network/wallet layer (hooks/useBatchDeposit.ts) is exactly right — the pure module has 262 lines of thorough unit tests covering selection totals, chunking, urgency banding, and progress calculation. The component is well-structured with proper dialog flow, per-pool progress, and retry semantics.

Blocking issues

1. Build is failing — the CI shows Build ❌. This is the primary reason for requesting changes. The pnpm build step must pass before this can be merged. Please check the Next.js build output for TypeScript or import errors. Common causes:

  • Missing loading-skeletons exports if PR #233 conflicts
  • Circular imports between batch-deposit.ts and hooks/useBatchDeposit.ts
  • Missing Spinner component import in batch-deposit-progress.tsx

2. E2E is also failing — same root cause likely.

Non-blocking observations

  1. submitContractTx refactoring (useJointSaveContracts.ts) — renaming submitTx to submitContractTx and adding onPhase callback is a clean API improvement. However, this change touches every hook that calls submitTx (deposit, withdraw, trigger payout, etc.) — if there are merge conflicts with other PRs that modify these same call sites, they will be painful. Consider whether this refactor could be a separate PR to reduce blast radius.

  2. useBatchDeposit.ts:137getPoolsRequiringDeposit scans all member pools concurrently with SCAN_CONCURRENCY = 4. If a user is in 50+ pools, this fires ~50 RPC calls in waves of 4. Consider adding a timeout per pool or a total timeout for the scan.

  3. batch-deposit-panel.tsx:563 — early return after useEffect calls means the hook still runs refresh() on mount even when the panel will render nothing. Minor perf concern.

  4. logDepositActivity uses a PATCH to /api/pools?id=... — verify the PATCH route handles the activity body shape, since errors are silently swallowed by try/catch.

Fix the build failures and this is ready to merge.

Fury03 added 2 commits August 20, 2026 01:39
…operations

# Conflicts:
#	frontend/app/api/pools/route.ts
#	frontend/hooks/useJointSaveContracts.ts
#	frontend/package.json
The E2E run caught a real integration bug that the component tests missed.

`onDepositsComplete` fired as soon as the run finished. The dashboard reloads
its pool list from that callback, and `MyGroups` returns a loading skeleton
while that fetch is in flight — which unmounts BatchDepositPanel, destroying
the dialog and the per-pool results the moment they appeared. The component
tests rendered the panel standalone with no callback, so they never saw it.

The parent is now notified when the dialog is dismissed, and only if something
was actually deposited. Two regression tests cover both halves: the callback
must not fire while the progress list is still on screen, and it must not fire
at all when the dialog is closed without depositing.

Also in this commit:

- prettier on e2e/batch-deposit.spec.ts, which failed `pnpm format:check`
- a 10s per-pool timeout on the state reads in getPoolsRequiringDeposit, so a
  wallet in dozens of pools can't be held behind one unresponsive RPC read
  (review feedback)
@Fury03

Fury03 commented Aug 20, 2026

Copy link
Copy Markdown
Author

Thanks for the review. Conflicts are resolved (merged upstream/main) and both failing checks are fixed. Details below, including one correction on the root cause and one genuine bug your E2E run caught.

1. The failing check wasn't the build

The Next.js build never ran. Frontend – Lint & Format Check runs pnpm lintpnpm format:checkBuild Next.js & Check Performance Budget, and it exited at step 2:

Checking formatting...
[warn] e2e/batch-deposit.spec.ts
[warn] Code style issues found in the above file. Run Prettier with --write to fix.
 ELIFECYCLE  Command failed with exit code 1.

prettier --write on that one file fixes it — pnpm format:check is clean now. For completeness I also ran the step that was never reached:

✓ Compiled successfully in 87s
✓ Generating static pages (36/36)
Shared Chunks Size: 0.00 KB (Budget: < 80 KB)  ✅

On the three causes you suggested: no circular import exists (lib/batch-deposit.ts imports nothing from the hook — the dependency runs one way only), and Spinner is exported from components/ui/spinner.tsx and imported correctly. So neither was in play here.

2. E2E was a different, real bug — and a good catch

Not the same root cause. Two of the three batch specs passed; only the full-run spec failed, at the point where the progress list should appear.

onDepositsComplete fired the moment the run finished. The dashboard reloads its pool list from that callback, and MyGroups returns its loading skeleton while that fetch is in flight — which unmounts BatchDepositPanel, tearing down the dialog and the per-pool results exactly as they appeared. My component tests rendered the panel standalone with no callback, so they never exercised it. That's a real gap in the tests, not just the E2E.

Fixed: the parent is notified when the dialog is dismissed, and only if something was actually deposited. Two regression tests now cover both halves — the callback must not fire while the progress list is still on screen, and must not fire at all when the dialog is closed without depositing.

3. submitContractTx blast radius

Fair concern, and it landed exactly where you predicted — #236 (gasless sponsorship) touched the same three deposit call sites. Resolution was mechanical: I kept #236's buildAndSubmitDeposit(...) wrapper wholesale and repointed its two internal submitTx(kit, tx, pendingTx) calls at submitContractTx(tx, { pendingTx }). No behaviour change on either side.

Worth noting the rename isn't cosmetic — the batch UI needs onPhase to drive per-pool status, and the alternative was duplicating the whole simulate → assemble → sign → send → poll pipeline. Splitting it into its own PR now would mean re-resolving these same conflicts twice, so I'd suggest keeping it here, but happy to split it if you'd rather.

4. Scan timeout — added

Good call. Added a 10s per-pool budget on the state reads in getPoolsRequiringDeposit; a pool that blows it is dropped from the list rather than stalling the panel. A wallet in 50 pools can no longer be held behind one unresponsive RPC read.

5. refresh() on mount when the panel renders nothing

This one is unavoidable — the scan is what determines whether anything is owed, so it has to run before the panel can know to render nothing. It's /api/pools?member= plus contract reads only for rotational pools, and the panel returns null until it resolves, so nothing is painted meanwhile.

6. PATCH shape — verified

logDepositActivity sends { id, activity: { activity_type, user_address, amount, tx_hash } }, which matches the body.activity branch of the PATCH handler in app/api/pools/route.ts. Errors are swallowed deliberately: the deposit is already on-chain at that point, so a failed activity log must never surface as a failed deposit.

Verification after the merge

pnpm test:unit        176 passed  (22 new)
pnpm test:components  113 passed / 18 files  (12 new, +2 regression)
pnpm build            compiled successfully
pnpm check-budget     within budget
pnpm format:check     clean
pnpm lint             0 errors (6 pre-existing warnings, all in untouched files)
tsc --noEmit          0 errors in any file this PR touches

One thing I can't do from my side: the new CI runs are sitting at action_required and need a maintainer to approve them before they'll execute. Could you kick those off when you get a chance?

@Sendi0011 Sendi0011 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: Batch Deposit System — APPROVED + MERGING

Excellent work. Clean hook architecture, proper chunking with retry logic, and comprehensive stats UI.

Architecture

  • useBatchDeposit hook — Manages chunked deposits with configurable chunk size, retry logic, and abort support
  • useBatchDepositStats hook — Fetches and aggregates deposit statistics
  • BatchDepositPanel component — Full UI with progress tracking, chunk status, and retry controls
  • batch-deposits/page.tsx — Dedicated page with stats cards + deposit panel

Code Quality

  • useLocalStorage for persisting deposit preferences
  • AbortController integration for cancellation support
  • useCallback/useMemo properly used to prevent unnecessary re-renders
  • Error handling with proper ErrorInfo type and chunk-level error tracking

Previous Feedback Addressed

Author updated and addressed previously requested changes. All 5 CI checks now pass.

Non-blocking Notes

  • Hardcoded COUNTER_CONTRACT_ID and POOL_ADMIN_SECRET in batch-actions.ts — should be env vars (follows existing patterns)
  • Batch size cap of 200 members is reasonable but undocumented in the UI

Verdict

Production-ready batch deposit system. All 5 CI checks pass. Merging.

@Sendi0011

Copy link
Copy Markdown
Contributor

Hi @Fury03 — PRs #237 and #238 were just merged to main, which caused a merge conflict in your branch (likely in useJointSaveContracts.ts or package.json). Could you rebase on main and resolve the conflict? I will merge as soon as it is green. Thanks!

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.

[Feature] Implement batch transaction operations for depositing to multiple pools simultaneously

2 participants