fix: clawback allowance mechanism, invoice tax_bps overflow (#685, #686) - #841
Merged
Samuel1505 merged 1 commit intoJul 31, 2026
Merged
Conversation
…abs#685, PhasoraLabs#686) clawback::execute now pulls funds via a pre-authorized SEP-41 allowance (approve/transfer_from) instead of a plain transfer that required the target's live signature, so an uncooperative contributor can no longer block recovery once they've pre-authorized the pull. invoice::validate_line_items now rejects tax_bps > 10_000 by reusing the crate's existing math::basis_points_of helper, closing an overflow-panic DoS on invoice submission. Verified PhasoraLabs#687 (fee wiring) and PhasoraLabs#688 (performance bond proportional claim) are already fixed on main by prior unrelated commits; no code changes needed there. Also fixes a cluster of unrelated pre-existing build/test breakage (missing enum variant, missing Ledger trait imports, Grant::Default misuse, stale test fixture field, a use-after-move bug, a malformed as_contract call, and a redundant double require_auth() in the clawback_* entry points) that blocked compiling and running the test suite at all. See contracts/PR_685_686_687_688.md for full details.
|
@lycantho Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits. You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Closes #685. Closes #686. Closes #687. Closes #688.
All four issues target
contracts/contracts/stellar-grants/src/. Before writing any code I checked each one directly against currentmainrather than trusting the issue text at face value — two were still genuinely broken, two had already been fixed by unrelated prior work and just needed verification.mainbefore this PRmath::basis_points_ofdb08ab2e)74921af5/7be303bc)#685 —
clawback::executecan now actually recover fundsThe bug
execute()calledtoken::Client::transfer(&clawback.target, &treasury, &clawback.amount). SEP-41transferrequiresfrom.require_auth()— i.e. the contributor being clawed back from has to sign the transaction. Onlycaller(the admin executing the clawback) signed. An uncooperative contributor could simply never sign, permanently blocking recovery — exactly the scenario clawback exists for.The fix: pre-authorized SEP-41 allowance (option 1 from the issue)
I chose the pre-authorization/
transfer_fromapproach over restructuring payout into a hold-back/vesting model, for two reasons:execute()(lines ~161-214) as the fix site. Hold-back would mean redesigningescrow.rs's payout flow andlib.rs'sfinalize_grant_release(which pays out the entire grant in one lump sum, not per-milestone) — a materially larger, separate change.approve/transfer_frompair exists so a contract can move funds out of a wallet without needing that wallet's cooperation at the moment of the transfer — because the authorizing signature was already given earlier, voluntarily, by the wallet owner.New function
clawback::authorize_pull(env, contributor, grant_id, token, amount, live_until_ledger):contributor.require_auth()— the contributor's own signature, given while they're still cooperative (e.g. right after a milestone payout).token::Client::approve(contributor, contract_address, amount, live_until_ledger).execute()now does:transfer_from'sspender.require_auth()requirement is satisfied automatically for calls the contract makes as itself — no signature fromclawback.targetneeded at execute time. If no allowance was ever set (or it's insufficient),executenow returns a cleanContractError::InsufficientClawbackAllowanceinstead of letting the token contract panic.New entry point
clawback_authorize_pulladded tolib.rsalongside the other fiveclawback_*wrappers. New error variantContractError::InsufficientClawbackAllowance = 148. New eventClawbackAllowanceAuthorized.Proving it's not
mock_all_auths()papering over the gapThe issue specifically calls this out: "test this, don't just assume
mock_all_auths()masks the real-world gap." The decisive test,test_execute_succeeds_via_preauthorized_allowance_without_target_signature, deliberately avoids blanket mocking:env.mock_all_auths_allowing_non_root_auth()(records real auth requirements instead of blindly satisfying everyrequire_auth()call) and drives the real dispatched entry points (client.clawback_authorize_pull,client.clawback_execute) rather than calling module functions directly.execute, inspectsenv.auths()— the actual list of addresses whose signature the call required — and assertsadminis in it whileowner(the clawback target) is not. Under the oldtransfer-based code this call would have panicked (the token contract's ownfrom.require_auth()for the target has nothing to satisfy it); under the fix it succeeds without the target ever being asked.Other new tests:
test_execute_fails_without_allowance(clean error, no panic, when nothing was pre-authorized),test_authorize_pull_rejects_non_owner,test_authorize_pull_rejects_non_positive_amount. All 9 pre-existing clawback tests still pass, updated only to callauthorize_pullfirst wherever they reachexecute.A pre-existing gap this PR does not fix
While building the end-to-end test I found that no production code path (
governance.rs,lib.rs'sfinalize_grant_release/complete_grant) ever actually setsMilestoneState::Paid— milestones only ever reachApproved.clawback::initiaterequiresPaid. This means the clawback feature is currently unreachable end-to-end via the real payout flow, independent of the auth fix in this PR. This is outside #685's stated scope (which only points atexecute), and every pre-existing test inclawback.rsalready isolates the module the same way this PR's new tests do — by constructing a milestone with.state: MilestoneState::Paiddirectly rather than driving it through governance. Flagging this as a candidate follow-up issue.#686 —
invoice::validate_line_itemsrejects oversizedtax_bpsThe bug
No upper bound on caller-supplied
tax_bps: u32. Soroban builds with overflow checks enabled, so a large enough value panics the transaction — a clean DoS on invoice submission.The fix
Rather than hand-rolling
checked_mul/checked_divas the issue's suggested patch does, I reusedcrate::math::basis_points_of(amount, basis_points)— the crate's existing shared helper for exactly this computation (fees.rsalready uses it for protocol fee math). It already rejectsbasis_points > 10_000withContractError::InvalidInputand uses checked arithmetic internally:One-line fix, no duplicated overflow-checking logic, and it's the more idiomatic choice given the helper already exists and is already the crate's convention for bps math. Covers both
submit_invoiceandresubmit_invoicesince both funnel throughvalidate_line_items.New tests:
test_submit_invoice_rejects_tax_bps_over_10000(submits withtax_bps = 10_001, asserts a cleanInvalidInput, and that no partial invoice is left on record),test_validate_line_items_rejects_excessive_tax_bps(tax_bps = u32::MAX),test_validate_line_items_rejects_tax_bps_just_over_limit, plus atest_validate_line_items_accepts_valid_tax_bpsregression guard for the normal case.#687 / #688 — verified already resolved, no code changes
Checked both against current
mainand ran their full test suites:fees::deduct_and_split_feeis wired into the real payout path inlib.rs::finalize_grant_release(line ~536) and callsStorage::add_fees_collected, sototal_fees_collectedreturns real values.cargo test -p stellar-grants --lib fees::→ 10/10 pass, includingtest_deduct_and_split_fee_respects_reviewer_reward_splitandtest_deduct_and_split_fee_respects_both_splits, which directly assert the fee amount matches configuredprotocol_fee_bpsand that the reviewer-reward/revenue-share/treasury splits are each correct.performance_bond::claim_bondalready distributes proportionally acrossgrant.funders(mirroringescrow::refund_all's pattern) instead of winner-take-all, and locks the bond asClaimedafter the first successful claim.cargo test -p stellar-grants --lib performance_bond::→ 12/12 pass, includingtest_claim_bond_proportional_across_funders(two funders at a 60/40 split, each asserted to receive their exact share) andtest_claim_bond_rejects_double_claim.git logshows these landed viadb08ab2e("fix: wire up reviewer reward pool, fix grant pause tests, integrate bounty grants") and74921af5/7be303bc("test: add coverage for performance bond claims" / "fix: add reentrancy guards to reward claim paths") — unrelated PRs that happened to resolve the same underlying bugs these issues describe.Unrelated build breakage fixed to enable verification
origin/maindid not compile at the point this branch was cut — 1 lib-level error and 30 test-compile errors across 9 files with no relation to #685-#688 (lockup.rs,access_control.rs,audit.rs,compliance.rs,merkle.rs,milestone_extension.rs,open_review.rs,referral.rs,split_payment.rs). None of this could be verified or worked around, so it had to be unblocked to runcargo check/cargo testat all:ContractError::TooManyPublicReviewsvariant referenced byopen_review.rs(added,= 147).use soroban_sdk::testutils::Ledger;imports (mechanical, per-file).Grant: Default, which doesn't exist (Addresshas no meaningful default) — replaced with explicit struct literals.referral.rstest fixture (contract_id→client.address).Bytesclone inmerkle.rs.env.as_contract(&|| ...)call missing its first argument inlockup.rs.This got the crate compiling (
cargo check --lib✅,cargo test --no-run✅) but running the suite then surfaced a much larger problem: 341 of 643 tests failed at runtime, spanning ~60 unrelated modules, from causes unrelated to any of this — mainly this soroban-sdk version rejectingenv.storage()access outsideenv.as_contract(..), a pattern dozens of pre-existing tests never used. That is far beyond a mechanical fix and well outside the scope of four specific issues, so it was left alone, except in the two files this PR actually modifies (clawback.rs,invoice.rs), where it had to be fixed for those modules' own tests to run at all. After this PR:cargo test -p stellar-grants --lib→ 316 passed / 331 failed (up from 302/341 on the unblocked-but-otherwise-untouched baseline) — the remaining 331 failures are pre-existing and out of scope for this PR.A second, related pre-existing bug found and fixed in the same files
While making
clawback.rs's tests exercise real dispatched entry points, all sixclawback_*wrappers inlib.rsturned out to call.require_auth()themselves and then delegate to aclawback::*module function that also calls.require_auth()for the same address at the same invocation depth. This is a genuine redundant/duplicate authorization check (harmless inmock_all_auths()-blanket tests, but rejected outright under strict auth verification withError(Auth, ExistingValue)/ "frame is already authorized"). Removed the redundant outer.require_auth()call from all six wrappers — the module functions already enforce it.Test plan
cargo fmt --check(clean for all files touched)cargo clippy -p stellar-grants --lib --tests -- -D warnings— no warnings in any file this PR touches (pre-existing unrelated failures remain inwaitlist.rs,relay.rs, and one integration test, all untouched by this PR)cargo check --lib -p stellar-grantscargo check --workspace --target wasm32v1-nonecargo test -p stellar-grants --lib clawback::→ 13/13 passcargo test -p stellar-grants --lib invoice::→ 4/4 passcargo test -p stellar-grants --lib fees::→ 10/10 pass (verifies Wire Up Protocol Fee Collection — Currently Entirely Dead #687)cargo test -p stellar-grants --lib performance_bond::→ 12/12 pass (verifies Fix performance_bond::claim_bond Paying the Full Bond to a Single Funder #688)cargo test -p stellar-grants --lib(whole crate) — does not pass; 331 pre-existing, unrelated failures documented above, out of scope for this PR