Skip to content

fee-abstraction: clear residual allowance after eager fee collection - #837

Open
knQzx wants to merge 2 commits into
OpenZeppelin:mainfrom
knQzx:fix/fee-abstraction-residual-allowance
Open

fee-abstraction: clear residual allowance after eager fee collection#837
knQzx wants to merge 2 commits into
OpenZeppelin:mainfrom
knQzx:fix/fee-abstraction-residual-allowance

Conversation

@knQzx

@knQzx knQzx commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

in Eager mode collect_fee approves max_fee_amount but only spends fee_amount, so the unspent allowance from the user to the forwarder stays alive after collection. since the forwarder goes on to invoke an arbitrary target, that leftover allowance can be spent by a later call

this consumes the unspent remainder back to the user with a net-zero transfer right after the fee is taken, leaving no residual allowance. Lazy mode is untouched because its remaining allowance is intentional for batching

cargo test for the crate passes and both forwarder examples still forward correctly

Summary by CodeRabbit

  • Bug Fixes
    • Eager fee approval now automatically returns any unused approved amount after the fee is collected.
    • Remaining fee allowance is cleared, preventing residual approvals.
  • Tests
    • Updated coverage to verify the refund event and zero remaining allowance.

@coderabbitai

coderabbitai Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Important

Review skipped

Auto incremental reviews are disabled on this repository.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 05d7b77c-eca4-4516-aaf8-99dfc9900daa

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Walkthrough

Eager fee collection now refunds the unused portion of max_fee_amount to the user. The eager approval test verifies the refund event and confirms that no allowance remains.

Changes

Eager fee allowance refund

Layer / File(s) Summary
Eager refund and validation
packages/fee-abstraction/src/storage.rs, packages/fee-abstraction/src/test.rs
Eager mode refunds unused allowance after fee collection. The test expects the refund event and a zero remaining allowance. Lazy mode remains unchanged.

Estimated code review effort: 2 (Simple) | ~10 minutes

Suggested reviewers: brozorec

Poem

I’m a rabbit with coins in my nest,
Unused fees now hop back to rest.
The allowance is cleared,
The refund event cheered,
Eager collection now does its best!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the main change: clearing residual allowance after eager fee collection.
Description check ✅ Passed The description explains the issue, implementation, mode-specific behavior, and testing, but it omits the issue reference and checklist.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
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.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 2

🤖 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.

Inline comments:
In `@packages/fee-abstraction/src/storage.rs`:
- Around line 211-215: Update the eager-approval cleanup in
collect_fee_and_invoke to clear the remaining allowance without transferring
tokens from the user or requiring additional user balance, using the
allowance-only authorization available in the eager authorization tree. Preserve
cleanup when fee_amount is below max_fee_amount, and add a regression test where
the user balance equals fee_amount that verifies the target call succeeds.

In `@packages/fee-abstraction/src/test.rs`:
- Around line 74-79: Extend the assertions after the event-count check in the
fee abstraction test to compare events.events().get(2) with the eager refund
Transfer payload, using user.clone() for both from and to and max_fee_amount -
fee_amount as the amount, serialized with to_xdr(&e, &token_address). Preserve
the existing zero-allowance assertion.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: b8e9f087-5fbe-49de-905d-49bc4f562196

📥 Commits

Reviewing files that changed from the base of the PR and between 9b5ed96 and 3a7318e.

📒 Files selected for processing (2)
  • packages/fee-abstraction/src/storage.rs
  • packages/fee-abstraction/src/test.rs

Comment on lines +211 to +215
if let FeeAbstractionApproval::Eager = approval {
let remaining = max_fee_amount - fee_amount;
if remaining > 0 {
token_client.transfer_from(&e.current_contract_address(), user, user, &remaining);
}

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.

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

rg -n -C 12 \
  'fn transfer_from|spend_allowance|spend_balance|receive_balance' \
  packages || true

rg -n -C 12 \
  'MockToken|mint|balance|collect_fee_with_eager_approval_overwrites_allowance' \
  packages/fee-abstraction/src/test.rs || true

Repository: OpenZeppelin/stellar-contracts

Length of output: 50386


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

printf '%s\n' '--- fee-abstraction candidates ---'
fd -i 'fee|test|storage' packages/fee-abstraction
printf '%s\n' '--- relevant symbols ---'
rg -n -C 10 \
  'FeeAbstractionApproval|validate_fee_bounds|transfer_from|approve|eager|allowance|fee_amount|max_fee_amount' \
  packages/fee-abstraction
printf '%s\n' '--- fungible balance update implementation ---'
sed -n '250,390p' packages/tokens/src/fungible/storage.rs
printf '%s\n' '--- fee-abstraction source outline ---'
ast-grep outline packages/fee-abstraction/src/storage.rs

Repository: OpenZeppelin/stellar-contracts

Length of output: 50388


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

printf '%s\n' '--- exact token transfer implementation ---'
sed -n '285,385p' packages/tokens/src/fungible/storage.rs
printf '%s\n' '--- exact fee tests and mock token setup ---'
sed -n '15,85p' packages/fee-abstraction/src/test.rs
printf '%s\n' '--- all eager call sites ---'
rg -n -C 8 'FeeAbstractionApproval::Eager|collect_fee_and_invoke' \
  packages examples

Repository: OpenZeppelin/stellar-contracts

Length of output: 24839


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

printf '%s\n' '--- Base::update definition ---'
rg -n -A 90 -B 10 'pub fn update|fn update\(' packages/tokens/src/fungible/storage.rs
printf '%s\n' '--- balance storage helpers ---'
rg -n -A 45 -B 10 'pub fn balance|fn balance_data|InsufficientBalance|Base::update' \
  packages/tokens/src/fungible/storage.rs

Repository: OpenZeppelin/stellar-contracts

Length of output: 14358


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

python3 - <<'PY'
def update(balance, from_amount, to_amount):
    if balance < from_amount:
        raise ValueError("InsufficientBalance")
    balance -= from_amount
    balance += to_amount
    return balance

fee_amount = 20
max_fee_amount = 50
remaining = max_fee_amount - fee_amount

for initial_balance in (fee_amount, max_fee_amount):
    after_fee = update(initial_balance, fee_amount, fee_amount)
    try:
        after_cleanup = update(after_fee, remaining, remaining)
    except ValueError as exc:
        print({
            "initial_balance": initial_balance,
            "after_fee": after_fee,
            "cleanup": "fails",
            "error": str(exc),
        })
    else:
        print({
            "initial_balance": initial_balance,
            "after_fee": after_fee,
            "after_self_transfer": after_cleanup,
        })
PY

Repository: OpenZeppelin/stellar-contracts

Length of output: 329


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

python3 - <<'PY'
def transfer(balance, amount):
    if balance < amount:
        raise ValueError("InsufficientBalance")
    return balance - amount

fee_amount = 20
max_fee_amount = 50
remaining = max_fee_amount - fee_amount

for initial_balance in (fee_amount, max_fee_amount):
    after_fee = transfer(initial_balance, fee_amount)
    try:
        after_cleanup = transfer(after_fee, remaining)
    except ValueError as exc:
        print({
            "initial_balance": initial_balance,
            "after_fee": after_fee,
            "cleanup": "fails",
            "error": str(exc),
        })
    else:
        print({
            "initial_balance": initial_balance,
            "after_fee": after_fee,
            "cleanup": "succeeds",
            "after_debit": after_cleanup,
            "after_credit_to_same_user": after_cleanup + remaining,
        })
PY

Repository: OpenZeppelin/stellar-contracts

Length of output: 375


Avoid debiting the user to clear the eager allowance.

When fee_amount < max_fee_amount, the self-transfer still requires the user balance to cover max_fee_amount - fee_amount. If the user balance equals fee_amount, cleanup fails with InsufficientBalance, and collect_fee_and_invoke does not reach the target call. Use an allowance-only cleanup authorized in the eager authorization tree. Add a regression test for this balance.

🤖 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/fee-abstraction/src/storage.rs` around lines 211 - 215, Update the
eager-approval cleanup in collect_fee_and_invoke to clear the remaining
allowance without transferring tokens from the user or requiring additional user
balance, using the allowance-only authorization available in the eager
authorization tree. Preserve cleanup when fee_amount is below max_fee_amount,
and add a regression test where the user balance equals fee_amount that verifies
the target call succeeds.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Hey @knQzx the coderabbit finding seems a valid one: if the user's balance is below max_fee_amount - fee_amount the whole invocation would fail. One option is using try_transfer_from but then we might have to deal with residuals that are smaller than remaining. Another option is checking the user's balance and transferring only min(balance, remaining).

Comment on lines +74 to +79
// approval, fee transfer, residual refund and collect fee
assert_eq!(events.events().len(), 4);

// the unspent allowance is consumed back to the user in eager mode
let allowance = token_client.allowance(&user, &contract_address);
assert_eq!(allowance, 30);
assert_eq!(allowance, 0);

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.

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- candidate files ---'
git ls-files 'packages/fee-abstraction/**' | sed -n '1,120p'

printf '%s\n' '--- test structure ---'
if command -v ast-grep >/dev/null 2>&1; then
  ast-grep outline packages/fee-abstraction/src/test.rs --match '$_' --view compact 2>/dev/null | sed -n '1,160p' || true
fi

printf '%s\n' '--- relevant test lines ---'
sed -n '1,130p' packages/fee-abstraction/src/test.rs

printf '%s\n' '--- event definitions and uses ---'
rg -n -C 4 'Transfer|contractevent|events\(\)|to_xdr|fee_amount|max_fee_amount|allowance' packages/fee-abstraction packages --glob '*.rs' | sed -n '1,260p'

Repository: OpenZeppelin/stellar-contracts

Length of output: 23172


🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- fee-abstraction imports and declarations ---'
sed -n '1,120p' packages/fee-abstraction/src/lib.rs
sed -n '1,90p' packages/fee-abstraction/src/storage.rs
cat packages/fee-abstraction/Cargo.toml

printf '%s\n' '--- all typed transfer-event references ---'
rg -n -C 5 'fungible::.*Transfer|Transfer \{|TransferEvent|FeeCollected|contractevent' packages --glob '*.rs' | sed -n '1,300p'

printf '%s\n' '--- workspace dependency declarations ---'
rg -n -C 3 'stellar_tokens|stellar-tokens|soroban-sdk' Cargo.toml packages --glob 'Cargo.toml' | sed -n '1,220p'

printf '%s\n' '--- relevant test section ---'
sed -n '1,110p' packages/fee-abstraction/src/test.rs

Repository: OpenZeppelin/stellar-contracts

Length of output: 37678


🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- token package structure ---'
git ls-files packages/tokens/src | sed -n '1,160p'

printf '%s\n' '--- fungible module outline and event definitions ---'
if command -v ast-grep >/dev/null 2>&1; then
  ast-grep outline packages/tokens/src/fungible.rs --match '$_' --view compact 2>/dev/null | sed -n '1,220p' || true
fi
rg -n -C 8 'struct Transfer|enum Transfer|emit_transfer|transfer_from|contractevent' packages/tokens/src --glob '*.rs' | sed -n '1,320p'

printf '%s\n' '--- existing typed event assertions in tests ---'
rg -n -C 8 'to_xdr\(|events\(\)\.get|events\(\)\.first|events\(\)\.events\(\)' packages --glob 'test.rs' --glob '*.rs' | sed -n '1,320p'

Repository: OpenZeppelin/stellar-contracts

Length of output: 50386


🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- fungible event declarations ---'
rg -n -C 10 'struct Transfer|emit_transfer|contractevent' packages/tokens/src/fungible/mod.rs packages/tokens/src/fungible/storage.rs packages/tokens/src/fungible/overrides.rs

printf '%s\n' '--- fungible public exports ---'
sed -n '1,180p' packages/tokens/src/fungible/mod.rs

printf '%s\n' '--- nearby fungible event implementation ---'
python3 - <<'PY'
from pathlib import Path
p = Path("packages/tokens/src/fungible/mod.rs")
lines = p.read_text().splitlines()
for i, line in enumerate(lines):
    if "struct Transfer" in line or "emit_transfer" in line or "contractevent" in line:
        lo, hi = max(0, i - 8), min(len(lines), i + 35)
        print(f"--- lines {lo+1}-{hi} ---")
        for n in range(lo, hi):
            print(f"{n+1}:{lines[n]}")
PY

printf '%s\n' '--- typed event assertion conventions around indexed events ---'
rg -l 'events\(\)\.get|events\(\)\.first|to_xdr\(' packages --glob '*.rs' |
  while IFS= read -r f; do
    case "$f" in
      packages/tokens/*|packages/fee-abstraction/*)
        rg -n -C 4 'events\(\)\.(get|first)|to_xdr\(' "$f"
        ;;
    esac
  done | sed -n '1,260p'

Repository: OpenZeppelin/stellar-contracts

Length of output: 39261


🏁 Script executed:

#!/bin/bash
set -eu

python3 - <<'PY'
from pathlib import Path
import re

test = Path("packages/fee-abstraction/src/test.rs").read_text()
storage = Path("packages/fee-abstraction/src/storage.rs").read_text()
fungible = Path("packages/tokens/src/fungible/mod.rs").read_text()

# Confirm the test currently checks only the event count and not typed payloads.
assert "assert_eq!(events.events().len(), 4);" in test
assert "to_xdr" not in test

# Confirm the eager path emits approval, fee transfer, refund, then FeeCollected.
body = storage[storage.index("pub fn collect_fee("):storage.index("// ################## FEE TOKEN ALLOWLIST")]
sequence = [
    "token_client.approve(",
    "token_client.transfer_from(&e.current_contract_address(), user, fee_recipient, &fee_amount);",
    "token_client.transfer_from(&e.current_contract_address(), user, user, &remaining);",
    "emit_fee_collected(e, user, fee_recipient, fee_token, fee_amount);",
]
positions = [body.index(item) for item in sequence]
assert positions == sorted(positions), positions

# Confirm the refund is a non-muxed fungible Transfer event with the required fields.
assert re.search(
    r"Transfer\s*\{.*?from: from\.clone\(\),.*?to: to\.clone\(\),.*?amount",
    fungible,
    re.S,
)
assert "Transfer { from: from.clone(), to: to.clone(), amount }.publish(e);" in fungible
assert "let remaining = max_fee_amount - fee_amount;" in body

print("The count-only assertion omits the eager refund payload.")
print("The refund is event index 2 and is serialized by stellar_tokens::fungible::Transfer.")
print("Expected refund payload: from=user, to=user, amount=max_fee_amount-fee_amount.")
PY

Repository: OpenZeppelin/stellar-contracts

Length of output: 389


Assert the eager refund event payload.

Compare events.events().get(2) with stellar_tokens::fungible::Transfer { from: user.clone(), to: user.clone(), amount: max_fee_amount - fee_amount }.to_xdr(&e, &token_address). Keep the zero-allowance assertion.

🤖 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/fee-abstraction/src/test.rs` around lines 74 - 79, Extend the
assertions after the event-count check in the fee abstraction test to compare
events.events().get(2) with the eager refund Transfer payload, using
user.clone() for both from and to and max_fee_amount - fee_amount as the amount,
serialized with to_xdr(&e, &token_address). Preserve the existing zero-allowance
assertion.

Source: Coding guidelines

The self transfer moves the residual out of and back into the same
balance, so the token rejects it when the balance is short and the whole
invocation fails. Consume the affordable part instead.
@knQzx

knQzx commented Aug 19, 2026

Copy link
Copy Markdown
Contributor Author

good catch, went with min(balance, remaining).

the failure is real: the self transfer still goes through Base::update, which panics with InsufficientBalance when from_balance < amount, so a user who is short after paying the fee would lose the whole invocation.

i also looked at just calling approve(0) instead, but that needs owner.require_auth() and would force a second user auth entry, while transfer_from is authorized by the contract itself - so the self transfer stays, it just consumes what the balance allows.

what is left over is an allowance the user cannot spend anyway at the current balance, which felt better than reverting. two tests added: balance below the residual (5 of 30 consumed, 25 allowance left) and balance exactly the fee (no refund transfer at all).

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.

2 participants