Skip to content

feat(invitations): cross-leg token exclusions to prevent batch double-spends#56

Open
leinss wants to merge 1 commit into
mainfrom
feature/referral-leg-exclusions
Open

feat(invitations): cross-leg token exclusions to prevent batch double-spends#56
leinss wants to merge 1 commit into
mainfrom
feature/referral-leg-exclusions

Conversation

@leinss

@leinss leinss commented Jun 10, 2026

Copy link
Copy Markdown
Contributor

Summary

Composed Safe batches that contain multiple independently path-found legs (e.g. legacy-CRC migration + invitation transfer) can double-spend the same token balance: each leg's pathfinder call plans against the same live state, so both may source the same (wrapped) balance. On-chain, the first leg's unwrap consumes it and the second leg's unwrap of the same amount reverts ERC20InsufficientBalance during 4337 simulation. Rebuild-on-retry cannot fix this — rebuilding against unchanged chain state reproduces the same collision deterministically.

Changes

  • PermissionlessGroup.migration() now returns sourcedTokens — the token owners (avatars) the migration path spends from the avatar, wrapped entries resolved to their underlying avatar.
  • New getSourcedTokenOwnersFromPath() helper in @aboutcircles/sdk-pathfinder.
  • Invitations.generateReferral() / generateInvite() accept options.excludeFromTokens, forwarded to findInvitePath() / findFarmInvitePath() and into the pathfinder request.
  • Callers pass avatar addresses only — the pathfinder server expands an excluded avatar to its wrapped forms.

Additive only; no behavior change for existing callers.

Test plan

  • bun run build (tsc --build + all package builds) — green
  • Live verification against the production RPC: with exclusions applied, a previously colliding migration+referral composition produces disjoint unwrap sets, the invite leg still reaches the full 96 CRC, and the composed batch passes a chained eth_simulateV1; when exclusions make the target unreachable, the existing insufficient-balance error is thrown at build time instead of signing a doomed tx
  • Version bump + npm release (maintainer flow) — consumer apps need a release to pass excludeFromTokens

…-spends

When a Safe batch composes multiple independently path-found legs (e.g.
legacy-CRC migration + invitation transfer), each pathfinder call plans
against the same live balances, so both legs can source the same wrapped
token. On-chain the first leg's unwrap consumes the balance and the
second leg's unwrap reverts ERC20InsufficientBalance. Rebuilding on
retry reproduces the same collision deterministically.

- PermissionlessGroup.migration() returns sourcedTokens: the token
  owners the migration path spends from the avatar (wrapped entries
  resolved to the underlying avatar)
- new getSourcedTokenOwnersFromPath() helper in sdk-pathfinder
- Invitations.generateReferral()/generateInvite() accept
  options.excludeFromTokens, forwarded to findInvitePath()/
  findFarmInvitePath() and into the pathfinder request (the server
  expands an excluded avatar to its wrapped forms)

Additive only; no behavior change for existing callers.
@coderabbitai

coderabbitai Bot commented Jun 10, 2026

Copy link
Copy Markdown

Release Notes

  • New feature: Invitations.generateInvite() and Invitations.generateReferral() now accept an optional excludeFromTokens parameter to prevent double-spending in multi-leg batch compositions

    • Exclusion list is automatically propagated through internal pathfinding calls
    • Allows callers to specify token owners that should not be used as funding sources for the invitation leg
  • New helper function: getSourcedTokenOwnersFromPath() exported from @aboutcircles/sdk-pathfinder to identify which token-owner avatars are consumed by a pathfinding result

  • Enhanced return value: PermissionlessGroup.migration() now returns sourcedTokens field indicating which token owners are spent by the migration path

    • Callers can feed this directly into subsequent excludeFromTokens parameters to prevent collisions
  • API changes (backwards compatible):

    • findInvitePath() and findFarmInvitePath() now accept optional excludeFromTokens parameter
    • MigrationResult interface now includes sourcedTokens: Address[] field
  • Behaviour fix: Resolves issue where composed Safe batches including multiple independently path-found legs (e.g., legacy-CRC migration + invitation transfer) could fail with ERC20InsufficientBalance during 4337 simulation due to token balance double-spending

Walkthrough

This PR adds double-spend prevention by threading an optional excludeFromTokens parameter through invitation and pathfinding APIs. A new helper derives token owners spent within a pathfinding result. Migration results now include computed sourcedTokens that can be excluded in subsequent pathfinding operations.

Changes

Token owner exclusion mechanism

Layer / File(s) Summary
Pathfinder token-owner helper
packages/pathfinder/src/path.ts
New exported function getSourcedTokenOwnersFromPath derives the set of distinct token-owner avatars spent within a pathfinding result, resolving wrapped-token entries to their underlying avatars using token info metadata.
Migration result type and computation
packages/permissionless-groups/src/types.ts, packages/permissionless-groups/src/PermissionlessGroup.ts
MigrationResult now includes sourcedTokens: Address[] field. PermissionlessGroup.migration() computes this by building a token-info map from the pathfinding result and deriving sourced owners; both success and empty-result paths now include this field.
Invitation API signature updates
packages/invitations/src/Invitations.ts
generateInvite(inviter, invitee, options?) and generateReferral(inviter, options?) now accept optional options parameter with excludeFromTokens?: Address[] to prevent double-spending across multiple transaction legs.
Invitation parameter threading
packages/invitations/src/Invitations.ts
Internal helpers findInvitePath, findFarmInvitePath, and buildFarmOrFreeInviteTransactions now accept and forward excludeFromTokens to pathfinder.findPath calls. Both generateInvite and generateReferral thread the exclusion list through proxy-inviter and farm-or-free fallback paths.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 33.33% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely summarizes the main change: adding cross-leg token exclusions to prevent batch double-spends in invitation generation.
Description check ✅ Passed The description is well-detailed and directly related to the changeset, explaining the problem, changes made, compatibility, and test plan.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/referral-leg-exclusions

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/invitations/src/Invitations.ts (1)

337-346: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Apply excludeFromTokens before choosing the proxy inviter.

These branches still lock onto realInviters[0], but getRealInviters() is computed against the unexcluded state. If the first owner is in excludeFromTokens, findInvitePath() fails immediately and we never try the next proxy inviter or the farm path, even though one may still satisfy the invite. The same gap exists in findInvitePath() when proxyInviterAddress is omitted.

Also applies to: 448-480, 706-716

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/invitations/src/Invitations.ts` around lines 337 - 346, The code
picks realInviters[0] before applying options?.excludeFromTokens, causing
findInvitePath(inviterLower, realInviterAddress, ...) to fail if that address is
excluded; fix by filtering the realInviters list with options?.excludeFromTokens
(same filter used by getRealInviters) before selecting a proxy and instead
iterate through the filtered proxy candidates, calling
findInvitePath(inviterLower, candidate.address, options?.excludeFromTokens) for
each until one succeeds, and only then fall back to the farm path; also apply
the same candidate-filter-and-iterate approach inside findInvitePath() when
proxyInviterAddress is omitted to try each non-excluded proxy inviter rather
than assuming the first entry.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@packages/invitations/src/Invitations.ts`:
- Around line 337-346: The code picks realInviters[0] before applying
options?.excludeFromTokens, causing findInvitePath(inviterLower,
realInviterAddress, ...) to fail if that address is excluded; fix by filtering
the realInviters list with options?.excludeFromTokens (same filter used by
getRealInviters) before selecting a proxy and instead iterate through the
filtered proxy candidates, calling findInvitePath(inviterLower,
candidate.address, options?.excludeFromTokens) for each until one succeeds, and
only then fall back to the farm path; also apply the same
candidate-filter-and-iterate approach inside findInvitePath() when
proxyInviterAddress is omitted to try each non-excluded proxy inviter rather
than assuming the first entry.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: f202f5cc-2c1a-4bb9-baae-4e16f67b028f

📥 Commits

Reviewing files that changed from the base of the PR and between da905e5 and 5d42312.

📒 Files selected for processing (4)
  • packages/invitations/src/Invitations.ts
  • packages/pathfinder/src/path.ts
  • packages/permissionless-groups/src/PermissionlessGroup.ts
  • packages/permissionless-groups/src/types.ts

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