Skip to content

Production-ready multi-chain batch sender: Base Account + EIP-5792, testable core modules, security & UI polish - #1

Open
arena-ai-coding-agent[bot] wants to merge 6 commits into
mainfrom
arena/01a019cb-farsend
Open

arena-ai-coding-agent[bot] wants to merge 6 commits into
mainfrom
arena/01a019cb-farsend

Conversation

@arena-ai-coding-agent

@arena-ai-coding-agent arena-ai-coding-agent Bot commented Sep 20, 2026

Copy link
Copy Markdown

Overview

Turns FarSend into a production-grade, multi-chain batch sender that works with regular EOAs, Farcaster Mini App context, and Base Account (the passkey ERC-4337 smart wallet powering the Base App) — all through the same deployed, immutable BatchSender contracts, so no existing contracts need redeploying.

Based on the rewritten main (4966cdb). An independent external audit of the EIP-5792 layer was run on this PR; all its findings were verified against the final EIP-5792 spec and fixed — see Response to external audit below.

Response to external audit

The audit's verdict ("do not merge until the 5792 layer is spec-correct and cannot double-send") was correct. Every blocker was reproduced locally, verified against the final EIP-5792 spec, and fixed in 986e915:

# Finding Resolution
1 Double-send after wallet_sendCalls (non-rejection errors — incl. poll timeout — fell through to a second sendTransaction) Fixed. Once wallet_sendCalls resolves the batch is never resubmitted: poll timeout → "pending in your wallet, do not resubmit"; FAILED/UNKNOWN → surfaced, no auto-retry. Fallback to the signer path only when classifySendCallsError() proves nothing was submitted (method-not-found, invalid-params, bundle-too-large… — exactly the case the spec's Backwards-Compatibility section sanctions). Full decision table unit-tested, incl. "never fallback once submitted".
2 Capability detection wrong (no account param; sendCalls/atomicBatch flags; hardcoded 0x2105 fallback) Fixed. wallet_getCapabilities([account, [chainIdHex]]), checks atomic.status === 'supported' | 'ready' (per-chain or 0x0 global; explicit per-chain wins; legacy draft booleans tolerated; the 0x2105 cross-chain fallback is removed — unit-tested).
3 Wrong chainIdHex in chains.json (Base 0x2141, Avalanche 0xa882) Fixed (0x2105, 0xa86a) and check-chains.mjs now asserts parseInt(chainIdHex,16) === chainId — negative-tested to fail on the old values.
4 package-lock.json deleted Restored (commit 986e915) — reproducible npm ci for a fund-moving app. Note: the deletion (20f1f5f) was a manual commit on this branch; restore is per the audit's recommendation — easy to revert if intentional.
5 "Fallback RPC" never actually fell back (urls[0] only) Fixed. createFallbackProvider returns the full ordered provider list; readWithFallback fails over across endpoints (unit-tested: order, stop-on-success, last-error-when-all-down, rejection never swallowed).
6 Docs/CI lie (workflow claimed but not on GitHub; AUDIT marked debounce open) Made honest. README/ARCHITECTURE now say the workflow is written but not active (automation account lacks the workflows permission); AUDIT.md marks debounce done and records this audit round.
7 handleDispatch didn't re-check the burn gate Fixed. Re-runs findBurnRecipients + burnConfirmed inside the handler, alongside MAX_RECIPIENTS.

Plus two pre-existing items fixed while in there: token symbol() (contract-controlled) was interpolated raw into innerHTML in two places — now escaped (escapeHtml, unit-tested); and the burn-warning total is now an exact scaled-BigInt decimal sum (no float artifacts in the security panel).

Still open / out of scope (unchanged from prior notes): CI not running on GitHub (permission), guaranteed gas sponsorship (needs app registration/paymaster), dead webhookUrl (skipped by request), runtime-fetched chains.json canary, SafeERC20 (would require contract redeploy — intentionally avoided), parseFloat in non-security display paths.

Commits

  1. Add BatchSender contract, multi-chain config, testable core modules, and docs
  2. Rewrite app around core modules: Base Account + EIP-5792 dispatch, lazy Reown, UI polish
  3. Remove dedicated Base Account button — standard Reown modal is the single entry point (featured first); 5792 dispatch unchanged
  4. Address external audit — spec-correct EIP-5792, no-double-send invariant, chain hex fixes + drift guard, RPC failover, burn re-check, symbol XSS, exact burn total, lockfile, honest docs

Key changes

Base Account / Base App smart wallet support

  • Base Account featured first in the Reown wallet modal (official wallet ID from Base docs); standard AppKit connect flow — no separate connect surface
  • EIP-5792 dispatch (spec-conformant): capability detection via wallet_getCapabilities([account, [chainId]]) → atomic supported|ready; batch submitted with an app-provided id via wallet_sendCalls; status tracked via wallet_getCallsStatus numeric codes to a terminal state; the no-double-send invariant is enforced and unit-tested
  • Seamless fallback to the standard gas-estimated signer path for EOAs; user rejections (4001/4100) always surfaced, never swallowed
  • ERC20 dispersements send value=0x0 in the batch (non-payable disperseToken); only ETH batches attach the ETH value

Architecture (pure core modules, all unit-tested)

  • src/core/: parse (+escapeHtml), validate (burn detection, exact totals), distribute, debounce, errors, rpc (multi-endpoint failover), sendCalls (EIP-5792 + error-classification decision table)
  • 91 Vitest unit tests; drift guard keeps chains.json in sync with AppKit networks and validates chainIdHex (npm run check:chains)
  • Debounce + generation-token on the async summary/allowance path
  • Centralized error handling with ABI revert-reason decoding; read-only multi-endpoint RPC fallback (signing always uses the connected wallet)
  • Reown AppKit lazy-loaded after first paint — main bundle ~36 kB raw / ~11 kB gzip; ~400 kB-gzip Reown chunk deferred

Security & safety

  • Reviewed stateless BatchSender.sol (no owner/upgrade/withdraw); ABI mirrored in chains.json
  • Burn/dead-address confirmation gate — enforced in UI and re-checked in the dispatch handler
  • Gas-estimation failure is fatal; MAX_RECIPIENTS = 500; contract-derived strings escaped before any HTML interpolation
  • AUDIT.md (active, both audit rounds recorded) + ARCHITECTURE.md design notes

Multi-chain config

  • 8 chains: Base, Ethereum, Optimism, Arbitrum, BSC, Avalanche, Polygon PoS, LitVM LiteForge — chainIdHex values verified against numeric IDs by the drift guard
  • Per-chain fallback RPC URLs now actually used (provider-list failover)
  • Adding a new chain = deploy the same BatchSender.sol + one chains.json entry; old deployments untouched

UI/UX & infra polish

  • Consistent SVG icon system; ARIA live region for notifications
  • vercel.json duplicate redirects key fixed (Farcaster manifest redirect was silently dropped)

Verification

  • node --check clean on all modules
  • npm test91/91 passing (incl. the no-double-send decision table, spec-shaped 5792 tests, RPC failover order, hex-drift guard negative test)
  • npm run check:chains → OK; negative-tested to reject the old wrong hex values
  • npm run build → clean
  • Spec conformance re-verified against the final EIP-5792 text (params, result shapes, status codes, error codes, backwards-compatibility fallback clause)

Notes / follow-ups (intentionally out of scope)

  • .github/workflows/ci.yml ready locally, not on GitHub (GitHub App lacks workflows permission). It should use npm ci (lock file restored), check:chains (now with hex assertion), and add node --check for all src/core/* modules.
  • Guaranteed gas sponsorship on Base Account requires app registration + paymaster config; the current path lets the smart wallet apply its own gas policies (capabilities.paymasterService can be passed later).
  • Dead webhookUrl in the Farcaster manifest left as-is per request.
  • End-to-end wallet/passkey testing needs to happen outside this sandbox (no real wallet connection available).

@vercel

vercel Bot commented Sep 20, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
farsend Ready Ready Preview Sep 22, 2026 6:45pm UTC

CryptoExplor and others added 2 commits September 20, 2026 18:40
…and docs

- Stateless BatchSender.sol (disperseEther/disperseToken) — reviewed version
  of the contract uploaded to main: identical code, plus NatSpec documenting
  that it has no owner, no upgrade path, and no withdraw function
- Multi-chain registry in public/chains.json (Base, Ethereum, Optimism,
  Arbitrum, BSC, Avalanche, Polygon PoS, LitVM LiteForge) with per-chain
  fallback RPC URLs; ABI sanity-checked against the contract
- Extract pure logic from main.js into src/core/ (parse, validate,
  distribute, debounce, errors, rpc, sendCalls) with 62 Vitest unit tests
- npm run check:chains drift guard keeps chains.json in sync with the
  AppKit network list
- AUDIT.md (security review) and ARCHITECTURE.md (design notes)
- Rename gitignore -> .gitignore; drop dead public/indexold.html
- Vite code-splitting + host allowlist for the preview proxy;
  package-lock.json re-synced so npm ci works (it failed against the
  previously committed lock file)

Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com>
…zy Reown, UI polish

Base Account (Base App passkey smart wallet) support:
- Dedicated 'Sign in with Base Account' connect button; Base Account
  featured first in the Reown wallet modal (official wallet ID from
  docs.base.org)
- EIP-5792 dispatch: when the connected wallet advertises wallet_sendCalls
  (detected via wallet_getCapabilities), the batch is submitted atomically
  to the smart wallet and tracked via wallet_getCallsStatus to a terminal
  state; standard gas-estimated signer path remains as fallback
- ERC20 dispersements send value=0x0 in the smart-wallet batch
  (disperseToken is non-payable); only ETH batches attach the ETH value

Architecture & robustness:
- Reown AppKit lazy-loaded after first paint (requestIdleCallback) — main
  bundle ~36 kB raw / ~11 kB gzip; the ~400 kB-gzip Reown chunk is deferred
- Centralized error handling with ABI revert-reason decoding
  (src/core/errors.js); read-only RPC fallback provider for
  balance/allowance reads — signing always uses the connected wallet
- Debounce + generation-token on the async summary/allowance path so
  switching chains/tokens can no longer apply stale results
- Gas-estimation failure is fatal (no more manual-gas broadcast that would
  guarantee a revert); MAX_RECIPIENTS=500 safety cap
- Burn/dead-address (zero + ...dead) confirmation gate: dispatch stays
  disabled until the user explicitly acknowledges

UI/UX:
- Consistent SVG icon system for notifications, section headers, and
  stepper checks (emojis removed); ARIA live region for notifications
- vercel.json: single redirects array — the Farcaster
  /.well-known/farcaster.json manifest redirect was silently dropped by a
  duplicate JSON key

Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com>
…dispatch

Base Account is a registered Reown wallet, so it connects through the
standard AppKit modal — a second connect surface was redundant. It stays
featured first in the wallet (featuredWalletIds) and the EIP-5792
wallet_sendCalls dispatch is unaffected, so smart-wallet users still get
atomic batch submission with wallet-side gas handling.

- Drop baseAccountBtn markup, handleBaseAccountConnect(), and its listener
- README/ARCHITECTURE now document the single-modal connect flow

Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com>
… hex fixes, RPC failover, hardening

Blockers (verified against the final EIP-5792 spec):

1. Double-send eliminated. The old dispatcher fell through to a second
   signer.sendTransaction on any non-rejection error — including after the
   batch was already with the wallet (poll timeout was misread as failure).
   Now: once wallet_sendCalls RESOLVES the batch is never resubmitted;
   poll timeout => 'pending in your wallet, do not resubmit'; FAILED/UNKNOWN
   => surface, no auto-retry. Fallback to the signer path only when
   classifySendCallsError() proves nothing was submitted (method-not-found,
   invalid-params, bundle-too-large, etc. — the case the spec's
   Backwards-Compatibility section sanctions). Decision table unit-tested.

2. Spec shapes corrected. wallet_getCapabilities now takes
   [account, [chainIdHex]] and checks atomic.status 'supported'|'ready'
   (per-chain or 0x0 global; legacy draft booleans tolerated; the hardcoded
   0x2105 cross-chain fallback is removed). wallet_sendCalls sends an
   app-provided id (wallet must echo it) and reads result.id (legacy
   batchId tolerated). wallet_getCallsStatus uses the numeric status codes
   (1xx/2xx/4xx/5xx/6xx; legacy strings tolerated); 5730 => UNKNOWN.

3. chains.json chainIdHex fixes (Base 0x2141 -> 0x2105, Avalanche 0xa882 ->
   0xa86a). check-chains.mjs now asserts parseInt(chainIdHex,16) === chainId
   so this drift class fails CI (negative-tested).

Serious items:

4. Burn gate re-checked inside handleDispatch (defense in depth alongside
   MAX_RECIPIENTS), not only via the disabled button.
5. Token symbol XSS: symbol() output is contract-controlled and was
   interpolated raw into innerHTML in two places; both now use escapeHtml
   (unit-tested).
6. package-lock.json restored — reproducible npm ci for a fund-moving app.
7. 'Fallback RPC' is now a real fallback: createFallbackProvider returns the
   full ordered provider list and readWithFallback fails over across
   endpoints (ethers v6 takes a single URL per provider, so failover is
   explicit and unit-tested, including the no-fallback-after-rejection rule).
8. burnTotal sums decimal strings exactly (scaled BigInt) — the burn warning
   can no longer show float artifacts.
9. Docs made honest: README/ARCHITECTURE no longer claim CI runs on GitHub
   (workflow written but held locally — the automation account lacks the
   workflows permission); AUDIT.md marks debounce done and records this
   audit round with its verification.

Tests: 62 -> 91 (spec-shaped capability detection, batch-id generation,
request/response shaping, numeric status mapping, 5730/timeout handling,
full classifySendCallsError decision table, RPC failover order, escapeHtml,
exact burn totals). node --check / check:chains / build all clean.

Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com>
Follow-ups from the re-audit (not merge blockers, cheap to close):

- EIP-5792 status 200 with an empty receipts array is still success: clear
  the form and do not re-enable Dispatch on a live batch. Message tells the
  user to check their wallet. (Previously the success UI was skipped, so a
  second click could re-send.)
- showNotification no longer interpolates the message into innerHTML. The
  toast body is textContent; the only HTML is our trusted SVG icons plus an
  optional explorer <a> built from txExplorerHref (https explorer URL + a
  32-byte hex hash). Token symbols in toasts are therefore escaped by
  construction.
- ARCHITECTURE.md 'Bottom line' updated — it still claimed 'no tests, and
  no CI' after both were added.

Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com>

This branch was successfully deployed

1 active deployment
Preview ed5986a4 Deployed Sep 22, 2026 by vercel[bot]
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.

1 participant