Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions packages/fee-abstraction/src/storage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,25 @@ pub fn collect_fee(

token_client.transfer_from(&e.current_contract_address(), user, fee_recipient, &fee_amount);

// In `Eager` mode the approval is scoped to this single collection, so the
// unspent `max_fee_amount - fee_amount` is consumed back to the user to
// leave no residual allowance for the target invocation to abuse. In `Lazy`
// mode the remaining allowance is intentional and left untouched.
//
// The self transfer still moves the amount out of and back into the same
// balance, so the token rejects it when the user cannot cover it. Only what
// the balance allows is consumed, and the allowance left over is by
// definition not spendable by the user's current balance.
if let FeeAbstractionApproval::Eager = approval {
let residual = max_fee_amount - fee_amount;
if residual > 0 {
let consumable = residual.min(token_client.balance(user));
if consumable > 0 {
token_client.transfer_from(&e.current_contract_address(), user, user, &consumable);
}
}
Comment on lines +216 to +223

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

}

emit_fee_collected(e, user, fee_recipient, fee_token, fee_amount);
}

Expand Down
88 changes: 85 additions & 3 deletions packages/fee-abstraction/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -71,16 +71,98 @@ fn collect_fee_with_eager_approval_overwrites_allowance() {
});

let events = e.events().all();
// approval, transfer and collect fee
assert_eq!(events.events().len(), 3);
// 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);
Comment on lines +74 to +79

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


let balance = token_client.balance(&recipient);
assert_eq!(balance, 20);
}

#[test]
fn collect_fee_with_eager_approval_consumes_only_the_affordable_residual() {
let e = Env::default();
e.mock_all_auths_allowing_non_root_auth();

let contract_address = e.register(MockContract, ());
let user = Address::generate(&e);
let token_address = e.register(MockToken, (user.clone(),));
let recipient = Address::generate(&e);
let other = Address::generate(&e);

let max_fee_amount = 50;

let token_client = TokenClient::new(&e, &token_address);
// the user keeps 25, which covers the fee but not the whole residual
token_client.transfer(&user, &other, &975);

e.as_contract(&contract_address, || {
// approve 50, spend 20
collect_fee(
&e,
&token_address,
20,
max_fee_amount,
100,
&user,
&recipient,
FeeAbstractionApproval::Eager,
);
});

// only the 5 left after the fee are consumed, the rest of the allowance stays
let allowance = token_client.allowance(&user, &contract_address);
assert_eq!(allowance, 25);

assert_eq!(token_client.balance(&user), 5);
assert_eq!(token_client.balance(&recipient), 20);
}

#[test]
fn collect_fee_with_eager_approval_and_no_residual_balance() {
let e = Env::default();
e.mock_all_auths_allowing_non_root_auth();

let contract_address = e.register(MockContract, ());
let user = Address::generate(&e);
let token_address = e.register(MockToken, (user.clone(),));
let recipient = Address::generate(&e);
let other = Address::generate(&e);

let max_fee_amount = 50;

let token_client = TokenClient::new(&e, &token_address);
// the user keeps exactly the fee, so nothing is left to consume
token_client.transfer(&user, &other, &980);

e.as_contract(&contract_address, || {
// approve 50, spend 20
collect_fee(
&e,
&token_address,
20,
max_fee_amount,
100,
&user,
&recipient,
FeeAbstractionApproval::Eager,
);
});

let events = e.events().all();
// approval, fee transfer and collect fee, without a residual refund
assert_eq!(events.events().len(), 3);

let allowance = token_client.allowance(&user, &contract_address);
assert_eq!(allowance, 30);

assert_eq!(token_client.balance(&user), 0);
assert_eq!(token_client.balance(&recipient), 20);
}

#[test]
fn collect_fee_with_lazy_approval_no_previous() {
let e = Env::default();
Expand Down
Loading