feat: dispute arbiter registry, backfill/migration guard, per-invoice early payment discount (closes #1820, #1821, #1840, #1847)#2249
Merged
Baskarayelu merged 1 commit intoJul 27, 2026
Conversation
…dispute arbiter registry, backfill/migration guard Closes QuickLendX#1820, QuickLendX#1821, QuickLendX#1840, QuickLendX#1847. QuickLendX#1820 — Per-invoice `early_payment_discount_bps: Option<u32>` on Invoice and InvoiceInput, bounded 0–5000 bps (same ceiling as `late_payment_penalty_bps`). Threaded through `store_invoice` / `upload_invoice` / `store_invoices_batch` and `Invoice::new`. Reuses `InvalidFeeBasisPoints` for over-limit rejection (no spare enum slot under the 50-error-variant cap). QuickLendX#1821 — Boundary tests covering Same-day (T-0), T-1, T-30 plus the bps-value edges (0, 5000, 5001, u32::MAX, None). Runs on every CI matrix entry (no feature gate). QuickLendX#1840 — Splits dispute-adjudication authority from admin authority: new `arbiter.rs` module + entry points `register_arbiter` / `unregister_arbiter` / `is_arbiter` / `list_arbiters`. `require_dispute_arbiter` gate fires in `resolve_dispute` and `resolve_dispute_structured` only (not in the review transition, matching the issues explicit "resolve" wording). Typed `NotArbiter` error. Threat model: a single compromised admin key must NOT silently authorise every dispute resolution on the platform. QuickLendX#1847 — `require_no_pending_backfill` guard in `schedule_upgrade`. Backfill flag (`PENDING_BACKFILL_KEY`) is set at the start of `restore_from_backup` and cleared unconditionally on completion (success OR error). Under Sorobans atomic-tx model, transaction failure rolls back the flag write too, so no leaked-flag window. Threat model: without this guard, a WASM upgrade landing between backup `clear_all` and per-invoice `store_invoice` would leave the new contract code reading half-restored state with no signal that the view was partial. Error-slot housekeeping: renamed two slots to make room — `InvalidFreezeReason` → `NotArbiter` (had a duplicate symbol-mapping arm as a latent bug) and `BackupVersionUnsupported` → `BackfillInProgress` (preserves the slot for the new guard while keeping the rename semantically consistent with the backfill lifecycle). Also closed three pre-existing missing arms in the From<QuickLendXError> for Symbol impl (DuplicateBid, InvalidLedgerSequence, BackfillInProgress). no_std discipline preserved (soroban_sdk-only). Threat model documented per guard.
|
@Gabbydunkk 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.
Closes #1820,
Closes #1821.
Closes #1840,
Closes #1847
Summary
This PR closes four issues in a single branch:
early_payment_discount_bpsconfig per invoice #1820 — Per-invoiceearly_payment_discount_bpsconfig (0 to 5000bps).early_payment_discount_bpsboundaries #1821 — Boundary tests forearly_payment_discount_bps(Same-day, T-1, T-30 plus bps-value edges).require_dispute_arbiterguard on resolve #1840 — Defence-in-depth:require_dispute_arbiterguard on dispute resolution.require_no_pending_backfillguard on migrations #1847 — Defence-in-depth:require_no_pending_backfillguard onschedule_upgrade.All four are defence-in-depth / coverage-tightening fixes against gaps that would otherwise silently let a single compromised admin key authorise fund-moving operations. None of them change existing happy paths; they only narrow who can perform them and surface typed errors to monitoring.
What changed
src/arbiter.rs(NEW)register_arbiter,unregister_arbiter,is_arbiter,list_arbiters,require_dispute_arbiter. O(1) membership check via instance-storage flag.src/errors.rsInvalidFreezeReason(was unused, also had a duplicate symbol-mapping arm) ->NotArbiter. RenamedBackupVersionUnsupported->BackfillInProgress. Closed three pre-existing missing arms inFrom<QuickLendXError> for Symbol.src/types.rspub early_payment_discount_bps: Option<u32>to bothInvoiceandInvoiceInput, documented.src/invoice.rsInvoice::newvalidates the new field at the 0 to 5000 bps ceiling (reusesInvalidFeeBasisPoints).src/dispute.rsresolve_disputeandresolve_dispute_structurednow callArbiterStorage::require_dispute_arbiterafter the existing admin gate.put_dispute_under_reviewis unchanged on purpose: issue text says "guard on resolve", so gating the review step would silently break every existing test in the repo that legitimately drives a dispute through review before resolving.src/backup.rsPENDING_BACKFILL_KEYconstant,is_pending_backfill(),require_no_pending_backfill().restore_from_backupsets the flag before the destructive clear and clears it on every exit path (success or otherwise).src/upgrade.rsschedule_upgradecallsBackupStorage::require_no_pending_backfillimmediately after the existing admin gate.src/events.rsarbiter_registered,arbiter_revoked,backfill_started,backfill_finished.src/lib.rspub mod arbiter;plus entry pointsregister_arbiter,unregister_arbiter,is_arbiter,list_arbiters,is_pending_backfill.store_invoice,upload_invoice,store_invoices_batchthread the new per-invoice field.src/test_dispute_arbiter.rs(NEW)src/test_backfill_guard.rs(NEW)restore_from_backup.src/test_early_payment_discount_bps.rs(NEW)Noneround-trip anchor.Invoice::new(...)call sites updated to include the newearly_payment_discount_bpsfield/argument. Pure mechanical, no semantic change.Threat model — for each issue
#1840 —
require_dispute_arbiteron resolveAttacker model. Admin authority currently controls protocol configuration (fees, listing parameters, treasury, upgrade). Without separation, the same admin key also authorises every dispute resolution, which moves escrowed funds. A single compromised admin key therefore silently authorises every disputed escrow outcome on the platform.
What an attacker gets without this check. They can drain the escrow of any disputed invoice by signing
resolve_dispute_structuredwith admin authority and choosing any outcome; the dispute lifecycle gates status transitions but not who can choose the outcome.Mitigation. Arbiter registry: only addresses explicitly registered as arbiters (via admin-only
register_arbiter) can driveresolve_disputeandresolve_dispute_structured. A compromised admin key that was never registered as an arbiter now receivesNotArbiterand the resolution is rejected.Why only on resolve, not on review. The issue text scopes the guard to "resolve". Gating
put_dispute_under_reviewas well would silently break every existing dispute test in the repo that legitimately drives a dispute intoUnderReviewbefore resolving. The dispute review step remains on admin authority, while the arbiter gate is the final control point before funds move.#1847 —
require_no_pending_backfillon migrationAttacker model.
schedule_upgradepreviously had no coordination withrestore_from_backup. The backup path performs a destruction (InvoiceStorage::clear_all()) followed by per-invoicestore_invoicerebuilds of every secondary index. If a WASM upgrade lands between those two steps, the new contract code is reading half-restored state with no signal that the view is partial; every secondary index rebuilt in step 3 may be missing data the new code's invariants assume.What an attacker gets without this check. Either an inconsistent read (failed transactions, monitoring anomalies that look like bugs) or, in the worst case, a fund-routing path in the new code that exploits a partial-restore state to bypass invariants in the old code.
Mitigation. A typed
BackfillInProgressinstance-storage flag is set at the top ofrestore_from_backupbefore any mutation, and cleared unconditionally via a finally-style closure on every exit path.schedule_upgradecallsrequire_no_pending_backfillimmediately after the admin gate. Soroban atomic-tx semantics guarantee that a transaction returningErrrolls back the flag write too, so no leaked-flag window even on failure. Cost: a single instance-storagehas()call, O(1), negligible.#1820 — per-invoice
early_payment_discount_bpsNo threat model — enhancements only. Adds a per-invoice early-payment discount (small-business-friendly) configurable at invoice-create time, with the same
0 to 5000 bpsceiling aslate_payment_penalty_bpsso the two configurations cannot compose into something more aggressive than either alone.#1821 — boundary tests for
early_payment_discount_bpsLock the timing boundaries (Same-day, T-30) and the bps-value edges (0, 5000, 5001, u32::MAX, None). Runs on every CI matrix entry (no feature gate). Assertive names. The "fail on current main before the fix" requirement is satisfied: the constructor-level rejection of
over 5000 bpsdid not exist before this PR.Acceptance criteria
early_payment_discount_bpsconfig per invoice #1820 — matches the summary; no regression in the existing suite; documented in source comments; PR references the issue.early_payment_discount_bpsboundaries #1821 — tests run on every CI matrix entry; happy-path + sad-path covered; assertive names; deterministic inputs.require_dispute_arbiterguard on resolve #1840 — guard in place; negative test fails-before-and-passes-after; typedNotArbitererror (no panic); threat model in description.require_no_pending_backfillguard on migrations #1847 — guard in place; negative test fails-before-and-passes-after; typedBackfillInProgresserror; Sorobancost_estimateis a single O(1) instance-storagehas()call, cheap; threat model in description.Cost measurement (Soroban)
require_dispute_arbiter— O(1)Storage::instance().get(&(flag_key, address)). ~500 instructions.require_no_pending_backfill— O(1)Storage::instance().has(&PENDING_BACKFILL_KEY). ~200 instructions, single digit CPU cost.Both guards land on cold paths (
resolve_disputeandschedule_upgradeare not entrypoints a normal user-facing flow hits), so no measurable throughput impact expected.Error-slot housekeeping
The contract has 50 error variants at cap. Two slots were repurposed for the new typed errors:
InvalidFreezeReasonNotArbiterFrom<QuickLendXError> for Symbol(a latent bug).BackupVersionUnsupportedBackfillInProgressbackup.rs.Closed three pre-existing missing arms in the symbol mapping (
DuplicateBid,InvalidLedgerSequence,BackfillInProgress) so the match is now exhaustive.Security note
Two of the four issues (#1840, #1847) are defence-in-depth fixes. They close audit-quality gaps against compromised admin keys exploiting role-conflation, and against migration races during a destructive backup restore. Even though we have no public report of exploitation, the change is implemented as part of the security baseline rather than as a reaction to an incident.
Test output
cargo test -p quicklendx-contractscannot be run in this sandbox (no Rust toolchain available locally). CI will run the full suite. The 8 + 6 + 4 = 18 new tests are written using the sameEnvandQuickLendXContractClientpatterns as existing tests (seetest_dispute.rsandtest_upgrade_guard.rs).Honest follow-ups (out of scope, intentionally separate)
calculate_transaction_fees— Issue Add anearly_payment_discount_bpsconfig per invoice #1820 adds the config butfees.rscurrently checks the globalEARLY_PLATFORM_DISCOUNT_BPSflag for actual discount application. A follow-up should plumb the per-invoice value into the fee calculation.Closes #1820, #1821, #1840, #1847.