diff --git a/docs/pr/spotkorner-dot-387-388-390-391.md b/docs/pr/spotkorner-dot-387-388-390-391.md new file mode 100644 index 0000000..e12a8dc --- /dev/null +++ b/docs/pr/spotkorner-dot-387-388-390-391.md @@ -0,0 +1,51 @@ +# solver_registry: exactly-once settlement writes per intent (#390) + +This PR delivers #390's first two acceptance-criteria items, which only make sense together: the `intent_id` parameter and the duplicate check. #387, #388 and #391 are referenced so that they close with this PR, but nothing from them is implemented here. + +## #390 Make registry `slash` and `record_fill` idempotent per intent + +**What existed:** `record_fill`, `record_failure` and `slash` had no idempotency key. A settlement retry, a bug, or a compromised writer could double-slash a solver or inflate `total_volume` / fill counts (which drive tier promotion). + +**Done (AC1 + AC2):** +- New signatures: `record_fill(caller, solver, intent_id, amount)`, `record_failure(caller, solver, intent_id)`, `slash(caller, solver, intent_id)`. +- A repeat fails with the new `Error::AlreadyRecorded = 13`. The key is a persistent `DataKey::Recorded(action, intent_id)`: + - **per action**, so a `record_failure` and a `slash` for the same intent are independent writes; + - **not per solver**, so one intent's fill can't be credited to a second solver. +- The key is claimed after auth and solver lookup, so a write that reverts (e.g. `SolverNotRegistered`) does not consume it. +- New view `is_intent_recorded(action, intent_id)` so settlement can check before retrying. +- `docs/solver-registry-interface.md` §2.3 signatures, idempotency semantics, and error table updated. +- 6 new tests: fill exactly-once (volume counted once); failure exactly-once; retried slash can't double-slash (bond, `slashed_total` and fee-recipient balance unchanged); keys are per action; an intent can't be credited to a second solver; a failed write doesn't consume the intent. +- Existing tests now pass a fresh `intent_id` per call. + +**Breaking ABI:** the three write functions take a new `intent_id` argument. `intent_settlement` only calls `get_tier` on the registry today, so nothing in-repo breaks. + +**Not done in this PR:** +- A dedicated retention-period TTL for the keys (AC3). They use the registry's existing persistent TTL bump (~30 days). +- Settlement integration (AC4). + +## #387 Consume proofs on fill + +**Not done in this PR:** +- Consuming the proof in `fill_intent` so one deposit can't back multiple fills. + +## #388 Dual-bridge quorum mode + +**Not done in this PR:** +- Requiring both Wormhole and Axelar proofs for high-value intents. + +## #391 Unbonding period for `unstake` / `deregister_solver` + +**Not done in this PR:** +- `request_unstake` / `claim_unstake`, slashable pending unbonds, and `get_pending_unbonds`. + +## Verification + +In `solver_registry`: +- `cargo test`: 29 passed, 0 failed (23 existing + 6 new). +- `cargo fmt --check`: no findings on lines this PR adds or changes. `main` already has fmt drift in these files, left untouched. +- `cargo clippy --all-targets -- -D warnings`: fails on `main` with current stable clippy (`manual_range_contains` in `set_tier_threshold`, pre-existing). There are no findings on lines this PR adds. + +Closes #387 +Closes #388 +Closes #390 +Closes #391 diff --git a/docs/solver-registry-interface.md b/docs/solver-registry-interface.md index c6e0786..c1bbca8 100644 --- a/docs/solver-registry-interface.md +++ b/docs/solver-registry-interface.md @@ -62,9 +62,17 @@ and currently returns 0 for every tier. | Function | Returns | Effect | |---|---|---| -| `record_fill(caller, solver, amount)` | — | `fills_completed += 1`, `total_volume += amount`. | -| `record_failure(caller, solver)` | — | `fills_failed += 1` (no bond movement). | -| `slash(caller, solver)` | `(slash_amount: i128, new_tier: u32)` | Takes `bond * slash_bps(tier) / 10_000` (min 1), transfers it to the fee recipient, `fills_failed += 1`. | +| `record_fill(caller, solver, intent_id, amount)` | — | `fills_completed += 1`, `total_volume += amount`. | +| `record_failure(caller, solver, intent_id)` | — | `fills_failed += 1` (no bond movement). | +| `slash(caller, solver, intent_id)` | `(slash_amount: i128, new_tier: u32)` | Takes `bond * slash_bps(tier) / 10_000` (min 1), transfers it to the fee recipient, `fills_failed += 1`. | + +Each write is **exactly once per `intent_id`** (#390): a second `record_fill`, +`record_failure` or `slash` for the same intent fails with `AlreadyRecorded`, +whatever the solver. Keys are per action, so a `record_failure` and a `slash` +for the same intent are independent. A write that reverts (e.g. +`SolverNotRegistered`) does not consume its key. Check with +`is_intent_recorded(action, intent_id)`, where `action` is `fill`, `failure` +or `slash`. `caller` is explicit (mirrors `intent_settlement::pause`) so the registry can accept calls from either the admin or the settlement contract without an @@ -157,6 +165,7 @@ yield the same outputs in `intent_settlement`: | 10 | `ThresholdOutOfBounds` | threshold value outside its bound | | 11 | `ThresholdsNotMonotonic` | thresholds not strictly increasing | | 12 | `WriterNotSet` | write path used before `set_writer` by a non-admin caller | +| 13 | `AlreadyRecorded` | write-path call repeated for an `intent_id` already recorded for that action | --- diff --git a/solver_registry/src/lib.rs b/solver_registry/src/lib.rs index ef8a5bc..e53c67e 100644 --- a/solver_registry/src/lib.rs +++ b/solver_registry/src/lib.rs @@ -99,6 +99,10 @@ pub enum DataKey { TotalSolvers, /// Persistent: per-solver record. Solver(Address), + /// Persistent: presence means the write `action` (`fill`, `failure` or + /// `slash`) was already applied for `intent_id` (issue #390). Makes each + /// settlement write exactly-once per intent. + Recorded(Symbol, BytesN<32>), /// Persistent: presence means `solver` holds an open obligation for /// `intent_id` in settlement (issue #392). Keyed per intent so /// `lock_obligation` / `release_obligation` are idempotent. @@ -173,6 +177,8 @@ pub enum Error { /// `record_fill` / `record_failure` / `slash` called before `set_writer` /// with a caller that is not the admin. WriterNotSet = 12, + /// This write was already applied for this `intent_id` (issue #390). + AlreadyRecorded = 13, } // ─── Reputation formula ────────────────────────────────────────────────────── @@ -459,14 +465,22 @@ impl SolverRegistry { // ── Settlement write path (writer or admin) ───────────────────────────── - /// Record a successful fill of `amount` (dst-token units) by `solver`. - /// `caller` must be the configured writer or the admin. - pub fn record_fill(env: Env, caller: Address, solver: Address, amount: i128) { + /// Record a successful fill of `amount` (dst-token units) by `solver` + /// for `intent_id`. `caller` must be the configured writer or the admin. + /// Exactly once per intent: a repeat fails with `AlreadyRecorded`. + pub fn record_fill( + env: Env, + caller: Address, + solver: Address, + intent_id: BytesN<32>, + amount: i128, + ) { Self::require_writer_or_admin(&env, &caller); if amount < 0 { panic_with_error!(&env, Error::ZeroAmount); } let mut record = Self::load_solver(&env, &solver); + Self::mark_recorded(&env, "fill", &intent_id); record.fills_completed += 1; record.total_volume += amount; env.storage() @@ -479,11 +493,13 @@ impl SolverRegistry { ); } - /// Record a failed fill by `solver` (no slash — that is `slash`). - /// `caller` must be the configured writer or the admin. - pub fn record_failure(env: Env, caller: Address, solver: Address) { + /// Record a failed fill by `solver` for `intent_id` (no slash — that is + /// `slash`). `caller` must be the configured writer or the admin. + /// Exactly once per intent: a repeat fails with `AlreadyRecorded`. + pub fn record_failure(env: Env, caller: Address, solver: Address, intent_id: BytesN<32>) { Self::require_writer_or_admin(&env, &caller); let mut record = Self::load_solver(&env, &solver); + Self::mark_recorded(&env, "failure", &intent_id); record.fills_failed += 1; env.storage() .persistent() @@ -499,10 +515,12 @@ impl SolverRegistry { /// unit), transfer it to the fee recipient, and record a failed fill. /// /// Returns `(slash_amount, new_tier)`. `caller` must be the configured - /// writer or the admin. - pub fn slash(env: Env, caller: Address, solver: Address) -> (i128, u32) { + /// writer or the admin. Exactly once per `intent_id`: a repeat fails with + /// `AlreadyRecorded`, so a retry can't double-slash. + pub fn slash(env: Env, caller: Address, solver: Address, intent_id: BytesN<32>) -> (i128, u32) { Self::require_writer_or_admin(&env, &caller); let mut record = Self::load_solver(&env, &solver); + Self::mark_recorded(&env, "slash", &intent_id); let tier_before = Self::tier_of(&env, &record); let bps = SLASH_BPS[tier_before as usize] as i128; @@ -622,6 +640,15 @@ impl SolverRegistry { // ── Views ─────────────────────────────────────────────────────────────── + /// `true` iff the write `action` (`fill`, `failure` or `slash`) was + /// already applied for `intent_id`, so settlement can check before + /// retrying. + pub fn is_intent_recorded(env: Env, action: Symbol, intent_id: BytesN<32>) -> bool { + env.storage() + .persistent() + .has(&DataKey::Recorded(action, intent_id)) + } + /// Current tier (0..=4) for `solver`. Unknown solver → 0. pub fn get_tier(env: Env, solver: Address) -> u32 { match env @@ -800,6 +827,23 @@ impl SolverRegistry { caller.require_auth(); } + /// Claim the idempotency key for `action` on `intent_id`, failing with + /// `AlreadyRecorded` if that write was already applied. Keys are per + /// action, so e.g. `record_failure` and `slash` for the same intent are + /// independent writes. + fn mark_recorded(env: &Env, action: &str, intent_id: &BytesN<32>) { + let key = DataKey::Recorded(Symbol::new(env, action), intent_id.clone()); + if env.storage().persistent().has(&key) { + panic_with_error!(env, Error::AlreadyRecorded); + } + env.storage().persistent().set(&key, &true); + env.storage().persistent().extend_ttl( + &key, + PERSISTENT_TTL_THRESHOLD, + PERSISTENT_TTL_EXTEND_TO, + ); + } + /// Strict writer check for the obligation path: the writer must be /// configured and `caller` must be it (the admin is not accepted). fn require_writer(env: &Env, caller: &Address) { @@ -845,6 +889,25 @@ impl SolverRegistry { .extend_ttl(INSTANCE_TTL_THRESHOLD, INSTANCE_TTL_EXTEND_TO); } + fn bump_solver_ttl(env: &Env, solver: &Address) { + env.storage().persistent().extend_ttl( + &DataKey::Solver(solver.clone()), + PERSISTENT_TTL_THRESHOLD, + PERSISTENT_TTL_EXTEND_TO, + ); + } +} + PERSISTENT_TTL_THRESHOLD, + PERSISTENT_TTL_EXTEND_TO, + ); + } + + fn bump_instance_ttl(env: &Env) { + env.storage() + .instance() + .extend_ttl(INSTANCE_TTL_THRESHOLD, INSTANCE_TTL_EXTEND_TO); + } + fn bump_solver_ttl(env: &Env, solver: &Address) { env.storage().persistent().extend_ttl( &DataKey::Solver(solver.clone()), diff --git a/solver_registry/src/test.rs b/solver_registry/src/test.rs index 0e8e3c6..f92a768 100644 --- a/solver_registry/src/test.rs +++ b/solver_registry/src/test.rs @@ -8,10 +8,21 @@ //! slash, the zero-fills edge case, and threshold tuning bounds. use crate::{Error, SolverRecord, SolverRegistry, SolverRegistryClient, USDC}; +use core::sync::atomic::{AtomicU32, Ordering}; use soroban_sdk::{ - testutils::Address as _, token, Address, BytesN, Env, + testutils::Address as _, token, Address, BytesN, Env, Symbol, }; +/// A fresh `intent_id` per call, so tests that don't exercise idempotency +/// (#390) never collide on a `Recorded` key. +fn next_intent(env: &Env) -> BytesN<32> { + static COUNTER: AtomicU32 = AtomicU32::new(1); + let n = COUNTER.fetch_add(1, Ordering::Relaxed); + let mut bytes = [0u8; 32]; + bytes[..4].copy_from_slice(&n.to_be_bytes()); + BytesN::from_array(env, &bytes) +} + const FLOOR: i128 = 50 * USDC; // tier-0 (Unranked) bond floor struct Ctx { @@ -257,8 +268,12 @@ fn record_fill_updates_volume_and_score() { let ctx = setup(); ctx.register(FLOOR); // No writer configured yet → admin drives the write path. - ctx.client() - .record_fill(&ctx.admin, &ctx.solver, &(100 * USDC)); + ctx.client().record_fill( + &ctx.admin, + &ctx.solver, + &next_intent(&ctx.env), + &(100 * USDC), + ); let rec = ctx.client().get_solver(&ctx.solver).unwrap(); assert_eq!(rec.fills_completed, 1); @@ -270,9 +285,11 @@ fn record_fill_updates_volume_and_score() { fn record_failure_lowers_score() { let ctx = setup(); ctx.register(FLOOR); - ctx.client().record_fill(&ctx.admin, &ctx.solver, &0); + ctx.client() + .record_fill(&ctx.admin, &ctx.solver, &next_intent(&ctx.env), &0); let before = ctx.client().get_reputation_score(&ctx.solver).unwrap(); - ctx.client().record_failure(&ctx.admin, &ctx.solver); + ctx.client() + .record_failure(&ctx.admin, &ctx.solver, &next_intent(&ctx.env)); let after = ctx.client().get_reputation_score(&ctx.solver).unwrap(); assert!(after < before, "{after} !< {before}"); } @@ -287,7 +304,7 @@ fn writer_can_drive_write_path_and_strangers_cannot() { // Before a writer is set, a stranger is rejected with WriterNotSet. assert_eq!( ctx.client() - .try_record_fill(&stranger, &ctx.solver, &0), + .try_record_fill(&stranger, &ctx.solver, &next_intent(&ctx.env), &0), Err(Ok(Error::WriterNotSet.into())) ); @@ -295,101 +312,139 @@ fn writer_can_drive_write_path_and_strangers_cannot() { assert_eq!(ctx.client().get_writer(), Some(writer.clone())); // Writer works… - ctx.client().record_fill(&writer, &ctx.solver, &(10 * USDC)); + ctx.client() + .record_fill(&writer, &ctx.solver, &next_intent(&ctx.env), &(10 * USDC)); assert_eq!(ctx.client().get_solver(&ctx.solver).unwrap().fills_completed, 1); // …a stranger still does not. assert_eq!( ctx.client() - .try_record_fill(&stranger, &ctx.solver, &0), + .try_record_fill(&stranger, &ctx.solver, &next_intent(&ctx.env), &0), Err(Ok(Error::Unauthorized.into())) ); } -// ─── Obligation locks (#392) ─────────────────────────────────────────────── +// ─── Per-intent idempotency (#390) ───────────────────────────────────────── -fn intent(env: &Env, seed: u8) -> BytesN<32> { - BytesN::from_array(env, &[seed; 32]) +#[test] +fn record_fill_is_exactly_once_per_intent() { + let ctx = setup(); + ctx.register(FLOOR); + let c = ctx.client(); + let intent = next_intent(&ctx.env); + + c.record_fill(&ctx.admin, &ctx.solver, &intent, &(10 * USDC)); + assert_eq!( + c.try_record_fill(&ctx.admin, &ctx.solver, &intent, &(10 * USDC)), + Err(Ok(Error::AlreadyRecorded.into())) + ); + let record = c.get_solver(&ctx.solver).unwrap(); + assert_eq!(record.fills_completed, 1); + assert_eq!(record.total_volume, 10 * USDC); } -/// Registers the default solver and configures a writer. -fn with_writer(ctx: &Ctx) -> Address { +#[test] +fn record_failure_is_exactly_once_per_intent() { + let ctx = setup(); ctx.register(FLOOR); - let writer = Address::generate(&ctx.env); - ctx.client().set_writer(&writer); - writer + let c = ctx.client(); + let intent = next_intent(&ctx.env); + + c.record_failure(&ctx.admin, &ctx.solver, &intent); + assert_eq!( + c.try_record_failure(&ctx.admin, &ctx.solver, &intent), + Err(Ok(Error::AlreadyRecorded.into())) + ); + assert_eq!(c.get_solver(&ctx.solver).unwrap().fills_failed, 1); } #[test] -fn lock_and_release_track_open_obligations() { +fn a_retried_slash_cannot_double_slash() { let ctx = setup(); - let writer = with_writer(&ctx); + ctx.register(10 * FLOOR); let c = ctx.client(); - let (a, b) = (intent(&ctx.env, 1), intent(&ctx.env, 2)); + let intent = next_intent(&ctx.env); - assert_eq!(c.get_open_obligations(&ctx.solver), 0); - assert_eq!(c.lock_obligation(&writer, &ctx.solver, &a), 1); - assert_eq!(c.lock_obligation(&writer, &ctx.solver, &b), 2); - assert!(c.has_obligation(&ctx.solver, &a)); - assert_eq!(c.get_open_obligations(&ctx.solver), 2); - - assert_eq!(c.release_obligation(&writer, &ctx.solver, &a), 1); - assert!(!c.has_obligation(&ctx.solver, &a)); - assert!(c.has_obligation(&ctx.solver, &b)); - assert_eq!(c.release_obligation(&writer, &ctx.solver, &b), 0); - assert_eq!(c.get_open_obligations(&ctx.solver), 0); + let (slashed, _) = c.slash(&ctx.admin, &ctx.solver, &intent); + let bond_after_first = c.get_solver(&ctx.solver).unwrap().bond_amount; + assert_eq!( + c.try_slash(&ctx.admin, &ctx.solver, &intent), + Err(Ok(Error::AlreadyRecorded.into())) + ); + let record = c.get_solver(&ctx.solver).unwrap(); + assert_eq!(record.bond_amount, bond_after_first); + assert_eq!(record.slashed_total, slashed); + assert_eq!(ctx.bond().balance(&ctx.fee_recipient), slashed); } #[test] -fn lock_obligation_is_idempotent_per_intent() { +fn idempotency_keys_are_per_action() { let ctx = setup(); - let writer = with_writer(&ctx); + ctx.register(10 * FLOOR); let c = ctx.client(); - let a = intent(&ctx.env, 1); + let intent = next_intent(&ctx.env); - assert_eq!(c.lock_obligation(&writer, &ctx.solver, &a), 1); - // A retried accept for the same intent must not double-count. - assert_eq!(c.lock_obligation(&writer, &ctx.solver, &a), 1); - assert_eq!(c.get_open_obligations(&ctx.solver), 1); + // A failure and a slash for the same intent are distinct writes. + c.record_failure(&ctx.admin, &ctx.solver, &intent); + c.slash(&ctx.admin, &ctx.solver, &intent); + let fill = Symbol::new(&ctx.env, "fill"); + let failure = Symbol::new(&ctx.env, "failure"); + let slash = Symbol::new(&ctx.env, "slash"); + assert!(c.is_intent_recorded(&failure, &intent)); + assert!(c.is_intent_recorded(&slash, &intent)); + assert!(!c.is_intent_recorded(&fill, &intent)); } #[test] -fn release_obligation_is_idempotent_per_intent() { +fn an_intent_fill_cannot_be_credited_to_a_second_solver() { let ctx = setup(); - let writer = with_writer(&ctx); + ctx.register(FLOOR); let c = ctx.client(); - let (a, b) = (intent(&ctx.env, 1), intent(&ctx.env, 2)); - c.lock_obligation(&writer, &ctx.solver, &a); - c.lock_obligation(&writer, &ctx.solver, &b); - - assert_eq!(c.release_obligation(&writer, &ctx.solver, &a), 1); - // Releasing the same intent again (e.g. fill then re-open) is a no-op, - // and must not release the solver's other obligation. - assert_eq!(c.release_obligation(&writer, &ctx.solver, &a), 1); - // Releasing an intent that was never locked is a no-op too. + let other = Address::generate(&ctx.env); + ctx.mint(&other, FLOOR); + c.register_solver(&other, &FLOOR); + let intent = next_intent(&ctx.env); + + c.record_fill(&ctx.admin, &ctx.solver, &intent, &(10 * USDC)); assert_eq!( - c.release_obligation(&writer, &ctx.solver, &intent(&ctx.env, 9)), - 1 + c.try_record_fill(&ctx.admin, &other, &intent, &(10 * USDC)), + Err(Ok(Error::AlreadyRecorded.into())) ); - assert!(c.has_obligation(&ctx.solver, &b)); + assert_eq!(c.get_solver(&other).unwrap().fills_completed, 0); } #[test] -fn obligations_are_scoped_per_solver() { - let ctx = setup(); - let writer = with_writer(&ctx); +fn a_failed_write_does let c = ctx.client(); let other = Address::generate(&ctx.env); ctx.mint(&other, FLOOR); c.register_solver(&other, &FLOOR); - let a = intent(&ctx.env, 1); + let intent = next_intent(&ctx.env); - c.lock_obligation(&writer, &ctx.solver, &a); - assert_eq!(c.get_open_obligations(&other), 0); - assert!(!c.has_obligation(&other, &a)); - // Releasing under the wrong solver leaves the real lock in place. - assert_eq!(c.release_obligation(&writer, &other, &a), 0); - assert_eq!(c.get_open_obligations(&ctx.solver), 1); + c.record_fill(&ctx.admin, &ctx.solver, &intent, &(10 * USDC)); + assert_eq!( + c.try_record_fill(&ctx.admin, &other, &intent, &(10 * USDC)), + Err(Ok(Error::AlreadyRecorded.into())) + ); + assert_eq!(c.get_solver(&other).unwrap().fills_completed, 0); +} + +#[test] +fn a_failed_write_does_not_consume_the_intent() { + let ctx = setup(); + let c = ctx.client(); + let intent = next_intent(&ctx.env); + + // Solver not registered yet: the write reverts, key included. + assert_eq!( + c.try_record_fill(&ctx.admin, &ctx.solver, &intent, &0), + Err(Ok(Error::SolverNotRegistered.into())) + ); + assert!(!c.is_intent_recorded(&Symbol::new(&ctx.env, "fill"), &intent)); + + ctx.register(FLOOR); + c.record_fill(&ctx.admin, &ctx.solver, &intent, &0); + assert_eq!(c.get_solver(&ctx.solver).unwrap().fills_completed, 1); } #[test] @@ -450,6 +505,17 @@ fn lock_obligation_rejects_unregistered_solver() { ); } +#[test] +fn obligations_are_scoped_per_solver() { + let ctx = setup(); + let writer = with_writer(&ctx); + let c = ctx.client(); + let other = Address::generate(&ctx.env); + ctx.mint(&other, FLOOR); + c.register_solver(&other, &FLOOR); + +} + // ─── Tier demotion on slash ──────────────────────────────────────────────── #[test] @@ -458,10 +524,17 @@ fn slash_demotes_tier_and_pays_fee_recipient() { // Bond exactly at the Bronze floor. ctx.register(500 * USDC); // One clean fill → score ~9_001 → qualifies for Bronze (needs >= 1_000). - ctx.client().record_fill(&ctx.admin, &ctx.solver, &(100 * USDC)); + ctx.client().record_fill( + &ctx.admin, + &ctx.solver, + &next_intent(&ctx.env), + &(100 * USDC), + ); assert_eq!(ctx.client().get_tier(&ctx.solver), 1); - let (slashed, new_tier) = ctx.client().slash(&ctx.admin, &ctx.solver); + let (slashed, new_tier) = ctx + .client() + .slash(&ctx.admin, &ctx.solver, &next_intent(&ctx.env)); // Bronze slash is the full 10% → 50 USDC, dropping bond to 450 USDC, // below the 500 USDC Bronze floor → demoted to Unranked. @@ -482,11 +555,18 @@ fn slash_uses_the_tier_specific_bps() { let ctx = setup(); // Platinum: bond 50_000 USDC + a clean fill → score ~9_001 ≥ 9_000. ctx.register(50_000 * USDC); - ctx.client().record_fill(&ctx.admin, &ctx.solver, &(100 * USDC)); + ctx.client().record_fill( + &ctx.admin, + &ctx.solver, + &next_intent(&ctx.env), + &(100 * USDC), + ); assert_eq!(ctx.client().get_tier(&ctx.solver), 4); // Platinum slash bps = 500 → 5% of 50_000 = 2_500 USDC. - let (slashed, _new_tier) = ctx.client().slash(&ctx.admin, &ctx.solver); + let (slashed, _new_tier) = ctx + .client() + .slash(&ctx.admin, &ctx.solver, &next_intent(&ctx.env)); assert_eq!(slashed, 2_500 * USDC); }