From d7b6cb6d30cb1acee0e0d3c7a968b19bc928ff6e Mon Sep 17 00:00:00 2001 From: KingDavid9999 Date: Sun, 18 Oct 2026 01:54:05 -0700 Subject: [PATCH] feat(creator-keys): add atomic bundle purchase for multiple keys (#1057) Add bundle_buy and get_bundle_quote so a buyer can purchase several creator keys in one transaction that succeeds or reverts as a unit. - bundle_buy(buyer, items: [{key_id, amount, max_price}]) runs every item through the shared buy logic; any failing item, including one that exceeds its max_price, reverts the whole bundle - Emit BundleExecuted with a per-item breakdown (key_id, amount, price_paid) and the total cost - get_bundle_quote(items) returns estimated cost and price impact (bps) per item, plus the total - Reject bundles with more than 10 items (BundleSizeExceeded) - Add BundleItemUnfilled so a partial fill caused by a supply or wallet cap reverts the bundle instead of succeeding short Refactor buy_keys_with_ref_and_slip into a public wrapper that calls require_auth and an internal buy_keys_core that returns the keys bought and total price. Behavior of existing buy paths is unchanged. Tests: bundle_buy.rs covers full success, event breakdown, revert on slippage / unregistered key / supply cap, quote vs execution accuracy, price impact, and the 10-item limit. Closes #1057 --- creator-keys/src/bundle.rs | 213 ++++++++++++++++++++ creator-keys/src/events.rs | 38 ++++ creator-keys/src/lib.rs | 47 ++++- creator-keys/tests/bundle_buy.rs | 336 +++++++++++++++++++++++++++++++ 4 files changed, 633 insertions(+), 1 deletion(-) create mode 100644 creator-keys/src/bundle.rs create mode 100644 creator-keys/tests/bundle_buy.rs diff --git a/creator-keys/src/bundle.rs b/creator-keys/src/bundle.rs new file mode 100644 index 00000000..7124f7a2 --- /dev/null +++ b/creator-keys/src/bundle.rs @@ -0,0 +1,213 @@ +//! Atomic bundle purchase of multiple creator keys (Issue #1057). +//! +//! * [`CreatorKeysContract::bundle_buy`] buys several keys in one transaction. +//! Every item is executed through the same buy implementation as a +//! stand-alone `buy_keys`, so all guards (pause, blacklist, whitelist, +//! caps, circuit breaker, fees, events) behave identically. Any failing item +//! returns an error, and a failed invocation reverts **all** state changes, +//! so the bundle succeeds or reverts as a unit. +//! * [`CreatorKeysContract::get_bundle_quote`] is a read-only estimate of the +//! same purchase. +//! +//! ### Slippage guard +//! +//! Each item carries a `max_price`: the ceiling on the total cost of that +//! item's `amount` keys. Because the bundle is atomic, one item exceeding its +//! ceiling reverts the entire bundle, so the per-item ceilings act as a single +//! guard over the whole bundle. + +use crate::{ + compute_bonding_curve_price, constants, events, read_registered_creator_profile, AuctionConfig, + ContractError, CreatorKeysContract, CreatorKeysContractArgs, CreatorKeysContractClient, +}; +use soroban_sdk::{contractimpl, contracttype, Address, Env, Vec}; + +/// Maximum number of items accepted by a single bundle. +pub const MAX_BUNDLE_ITEMS: u32 = 10; + +/// One line of a bundle: buy `amount` keys of `key_id`, paying at most +/// `max_price` in total for that line. +#[derive(Clone, Debug, Eq, PartialEq)] +#[contracttype] +pub struct BundleItem { + pub key_id: Address, + pub amount: u32, + pub max_price: i128, +} + +/// Estimate for one bundle item. +/// +/// `price_impact_bps` is the rise of the spot price caused by buying the item, +/// `(price_after - price_before) * 10_000 / price_before`, saturating at +/// `u32::MAX`. It is `0` while the key is still in its fixed-price auction. +#[derive(Clone, Debug, Eq, PartialEq)] +#[contracttype] +pub struct BundleItemQuote { + pub key_id: Address, + pub amount: u32, + pub estimated_cost: i128, + pub price_impact_bps: u32, +} + +/// Estimate for a whole bundle. +#[derive(Clone, Debug, Eq, PartialEq)] +#[contracttype] +pub struct BundleQuote { + pub items: Vec, + pub total_cost: i128, +} + +/// Rejects bundles with more than [`MAX_BUNDLE_ITEMS`] items. +fn validate_bundle(items: &Vec) -> Result<(), ContractError> { + if items.len() > MAX_BUNDLE_ITEMS { + return Err(ContractError::BundleSizeExceeded); + } + Ok(()) +} + +#[contractimpl] +impl CreatorKeysContract { + /// Atomically buys every item in `items` for `buyer`. + /// + /// Succeeds only if **all** items succeed within their `max_price`; + /// otherwise the call errors and no item's state change persists. Emits a + /// single [`events::BundleExecutedEvent`] (in addition to the usual + /// per-key buy events) and returns the same payload. + /// + /// # Errors + /// + /// - [`ContractError::BundleSizeExceeded`] — more than [`MAX_BUNDLE_ITEMS`] items. + /// - [`ContractError::SlippageExceeded`] — an item costs more than its + /// `max_price`. + /// - [`ContractError::BundleItemUnfilled`] — an item could not be filled in + /// full (e.g. supply or per-wallet cap). + /// - Any error a stand-alone `buy_keys` would return for an item. + pub fn bundle_buy( + env: Env, + buyer: Address, + items: Vec, + ) -> Result { + buyer.require_auth(); + validate_bundle(&items)?; + + let mut breakdown: Vec = Vec::new(&env); + let mut total_cost: i128 = 0; + + for item in items.iter() { + // `payment` is unbounded on purpose: the item's ceiling is enforced + // through `max_price`, so a price overrun surfaces as + // `SlippageExceeded` rather than as a silent partial fill. + let outcome = Self::buy_keys_core( + env.clone(), + item.key_id.clone(), + buyer.clone(), + item.amount, + i128::MAX, + Some(item.max_price), + None, + None, + )?; + + if outcome.keys_bought != item.amount { + return Err(ContractError::BundleItemUnfilled); + } + + total_cost = total_cost + .checked_add(outcome.total_price) + .ok_or(ContractError::Overflow)?; + breakdown.push_back(events::BundleItemBreakdown { + key_id: item.key_id, + amount: item.amount, + price_paid: outcome.total_price, + }); + } + + let executed = events::BundleExecutedEvent { + buyer: buyer.clone(), + items: breakdown, + total_cost, + ledger: env.ledger().sequence(), + }; + env.events() + .publish(events::bundle_executed_topics(&buyer), executed.clone()); + Ok(executed) + } + + /// Read-only estimate of what [`Self::bundle_buy`] would charge. + /// + /// Each item is priced key by key along the same curve (or fixed auction + /// price) that `bundle_buy` uses, so `estimated_cost` equals the + /// `price_paid` a successful execution at the same ledger state reports. + /// Pricing only: guards that can reject a buy (caps, cooldown, circuit + /// breaker, whitelist) are not simulated. + pub fn get_bundle_quote( + env: Env, + items: Vec, + ) -> Result { + validate_bundle(&items)?; + + let base_price: i128 = env + .storage() + .persistent() + .get(&constants::storage::KEY_PRICE) + .ok_or(ContractError::KeyPriceNotSet)?; + let mut quotes: Vec = Vec::new(&env); + let mut total_cost: i128 = 0; + + for item in items.iter() { + let profile = read_registered_creator_profile(&env, &item.key_id)?; + let supply_before = profile.supply; + + let auction: Option = env + .storage() + .persistent() + .get(&constants::storage::auction_config(&item.key_id)); + let auction_price = auction + .filter(|c| supply_before < c.auction_supply) + .map(|c| c.auction_price); + + let mut cost: i128 = 0; + let mut supply = supply_before; + for _ in 0..item.amount { + let price = match auction_price { + Some(p) => p, + None => compute_bonding_curve_price(&env, &item.key_id, base_price, supply)?, + }; + cost = cost.checked_add(price).ok_or(ContractError::Overflow)?; + supply = supply.checked_add(1).ok_or(ContractError::Overflow)?; + } + + let price_impact_bps = if auction_price.is_some() { + 0 + } else { + let before = + compute_bonding_curve_price(&env, &item.key_id, base_price, supply_before)?; + let after = compute_bonding_curve_price(&env, &item.key_id, base_price, supply)?; + if before > 0 && after > before { + let bps = ((after - before) as u128) + .checked_mul(10_000) + .ok_or(ContractError::Overflow)? + / (before as u128); + u32::try_from(bps).unwrap_or(u32::MAX) + } else { + 0 + } + }; + + total_cost = total_cost + .checked_add(cost) + .ok_or(ContractError::Overflow)?; + quotes.push_back(BundleItemQuote { + key_id: item.key_id, + amount: item.amount, + estimated_cost: cost, + price_impact_bps, + }); + } + + Ok(BundleQuote { + items: quotes, + total_cost, + }) + } +} diff --git a/creator-keys/src/events.rs b/creator-keys/src/events.rs index df43d63b..e780201f 100644 --- a/creator-keys/src/events.rs +++ b/creator-keys/src/events.rs @@ -1959,6 +1959,44 @@ pub fn batch_buy_completed_topics(buyer: &Address) -> (Symbol, Address) { (BATCH_BUY_COMPLETED_EVENT_NAME, buyer.clone()) } +/// Event name for an atomic bundle purchase (Issue #1057). +pub const BUNDLE_EXECUTED_EVENT_NAME: Symbol = symbol_short!("bundle"); + +/// Per-item line of a [`BundleExecutedEvent`]. +/// +/// `price_paid` is the gross cost of `amount` keys of `key_id`, i.e. the same +/// figure `KeysBoughtEvent::price_paid` reports for a stand-alone buy. +#[derive(Clone, Debug, Eq, PartialEq)] +#[contracttype] +pub struct BundleItemBreakdown { + pub key_id: Address, + pub amount: u32, + pub price_paid: i128, +} + +/// Stable bundle-executed event payload for downstream indexers. +/// +/// Event shape: +/// - topics: `(BUNDLE_EXECUTED_EVENT_NAME, buyer)` +/// - data: `BundleExecutedEvent` +/// +/// Emitted exactly once, only after **every** item in the bundle succeeded. +/// `total_cost` always equals the sum of `items[*].price_paid`, and `items` +/// preserves the order the buyer submitted. +#[derive(Clone, Debug, Eq, PartialEq)] +#[contracttype] +pub struct BundleExecutedEvent { + pub buyer: Address, + pub items: Vec, + pub total_cost: i128, + pub ledger: u32, +} + +/// Shared bundle executed event topics tuple. +pub fn bundle_executed_topics(buyer: &Address) -> (Symbol, Address) { + (BUNDLE_EXECUTED_EVENT_NAME, buyer.clone()) +} + /// Event name for batch sell completion. pub const BATCH_SELL_COMPLETED_EVENT_NAME: Symbol = symbol_short!("bat_sell"); diff --git a/creator-keys/src/lib.rs b/creator-keys/src/lib.rs index cdafa9a0..3bf176b1 100644 --- a/creator-keys/src/lib.rs +++ b/creator-keys/src/lib.rs @@ -9,6 +9,7 @@ use soroban_sdk::{ pub mod acl_dividend_twap_gov; pub mod acl_limits_merge_sunset; +pub mod bundle; /// Bonding-curve migration, key subscriptions, and atomic swaps. /// /// Declared here as part of Issue #953 — the file existed but had no `mod` @@ -213,6 +214,12 @@ pub enum ContractError { KeyAlertLimitReached = 108, /// No active price alert with the given id belongs to the wallet. AlertNotFound = 109, + // --- Atomic bundle purchase (Issue #1057) --- + /// A bundle contains more than [`bundle::MAX_BUNDLE_ITEMS`] items. + BundleSizeExceeded = 110, + /// A bundle item could not be filled in full (for example a supply or + /// per-wallet cap stopped the purchase early), so the whole bundle reverts. + BundleItemUnfilled = 111, } /// Errors raised by the staking entrypoints @@ -2413,6 +2420,13 @@ pub struct RoyaltyConfig { pub sell_fee_bps: u32, } +/// Internal result of the shared buy implementation. +pub(crate) struct BuyOutcome { + pub new_supply: u32, + pub keys_bought: u32, + pub total_price: i128, +} + /// Result of a single order in a batch buy. #[derive(Clone, Debug, Eq, PartialEq)] #[contracttype] @@ -5982,6 +5996,33 @@ impl CreatorKeysContract { max_slippage_bps: Option, ) -> Result { buyer.require_auth(); + Self::buy_keys_core( + env, + creator, + buyer, + quantity, + payment, + max_price, + referrer, + max_slippage_bps, + ) + .map(|outcome| outcome.new_supply) + } + + /// Shared buy implementation used by [`Self::buy_keys_with_ref_and_slip`] and + /// the atomic bundle entrypoint. It performs **no** `require_auth`; callers + /// must authorize `buyer` themselves (once per invocation frame). + #[allow(clippy::too_many_arguments)] + pub(crate) fn buy_keys_core( + env: Env, + creator: Address, + buyer: Address, + quantity: u32, + payment: i128, + max_price: Option, + referrer: Option
, + max_slippage_bps: Option, + ) -> Result { emergency_pause::assert_trading_allowed(&env, &creator)?; assert_global_trading_not_halted(&env)?; assert_key_trading_not_paused(&env, &creator)?; @@ -6495,7 +6536,11 @@ impl CreatorKeysContract { // Extend TTL for creator storage after successful buy extend_creator_ttl(&env, &creator); - Ok(profile.supply) + Ok(BuyOutcome { + new_supply: profile.supply, + keys_bought, + total_price, + }) } pub fn buy_key_with_referrer( diff --git a/creator-keys/tests/bundle_buy.rs b/creator-keys/tests/bundle_buy.rs new file mode 100644 index 00000000..8b2a00b9 --- /dev/null +++ b/creator-keys/tests/bundle_buy.rs @@ -0,0 +1,336 @@ +//! Integration tests for the atomic `bundle_buy` / `get_bundle_quote` +//! entrypoints (issue #1057). +//! +//! Acceptance criteria verified: +//! - Full bundle reverts when any single item fails +//! - All items execute successfully when all within `max_price` +//! - `BundleExecuted` emitted with correct per-item breakdown +//! - `get_bundle_quote` returns accurate estimates for all items +//! - Bundle rejected when item count exceeds 10 + +mod contract_test_env; + +use contract_test_env::{ + register_creator_keys, register_test_creator, set_pricing_and_fees, test_env_with_auths, +}; +use creator_keys::bundle::{BundleItem, MAX_BUNDLE_ITEMS}; +use creator_keys::{events, ContractError, CreatorKeysContractClient}; +use soroban_sdk::{ + testutils::{Address as _, Events}, + Address, Env, IntoVal, Symbol, Vec, +}; + +const KEY_PRICE: i128 = 1_000; +const CREATOR_BPS: u32 = 500; +const PROTOCOL_BPS: u32 = 500; +/// Generous ceiling for items that are expected to succeed. +const LOOSE_MAX: i128 = 1_000_000_000; + +fn setup(env: &Env) -> CreatorKeysContractClient<'_> { + let (client, _) = register_creator_keys(env); + let admin = set_pricing_and_fees(env, &client, KEY_PRICE, CREATOR_BPS, PROTOCOL_BPS); + let protocol_recipient = Address::generate(env); + client.set_protocol_fee_recipient(&admin, &protocol_recipient); + client +} + +fn creators(env: &Env, client: &CreatorKeysContractClient<'_>, n: u32) -> std::vec::Vec
{ + (0..n) + .map(|i| register_test_creator(env, client, &std::format!("creator{i}"))) + .collect() +} + +fn item(key_id: &Address, amount: u32, max_price: i128) -> BundleItem { + BundleItem { + key_id: key_id.clone(), + amount, + max_price, + } +} + +#[test] +fn all_items_execute_when_within_max_price() { + let env = test_env_with_auths(); + let client = setup(&env); + let keys = creators(&env, &client, 3); + let buyer = Address::generate(&env); + + let items = Vec::from_array( + &env, + [ + item(&keys[0], 2, LOOSE_MAX), + item(&keys[1], 1, LOOSE_MAX), + item(&keys[2], 3, LOOSE_MAX), + ], + ); + let receipt = client.bundle_buy(&buyer, &items); + + assert_eq!(receipt.items.len(), 3); + for (i, expected) in [2u32, 1, 3].iter().enumerate() { + assert_eq!(client.get_key_balance(&keys[i], &buyer), *expected); + assert_eq!(client.get_total_key_supply(&keys[i]), *expected); + } +} + +#[test] +fn bundle_executed_event_has_correct_per_item_breakdown() { + let env = test_env_with_auths(); + let client = setup(&env); + let keys = creators(&env, &client, 3); + let buyer = Address::generate(&env); + + let items = Vec::from_array( + &env, + [ + item(&keys[0], 2, LOOSE_MAX), + item(&keys[1], 1, LOOSE_MAX), + item(&keys[2], 3, LOOSE_MAX), + ], + ); + let quote = client.get_bundle_quote(&items); + let receipt = client.bundle_buy(&buyer, &items); + + let log = env.events().all(); + let matching: std::vec::Vec<_> = log + .iter() + .filter(|(_, topics, _)| { + topics + .get(events::TOPIC_EVENT_NAME_INDEX) + .map(|v| { + let name: Symbol = v.into_val(&env); + name == events::BUNDLE_EXECUTED_EVENT_NAME + }) + .unwrap_or(false) + }) + .collect(); + assert_eq!(matching.len(), 1, "exactly one BundleExecuted event"); + + let (_, topics, data) = &matching[0]; + let topic_buyer: Address = topics.get(1).unwrap().into_val(&env); + assert_eq!(topic_buyer, buyer); + + let event: events::BundleExecutedEvent = data.clone().into_val(&env); + assert_eq!(event, receipt); + assert_eq!(event.buyer, buyer); + assert_eq!(event.items.len(), 3); + + let mut sum = 0i128; + for i in 0..3u32 { + let line = event.items.get(i).unwrap(); + let q = quote.items.get(i).unwrap(); + assert_eq!(line.key_id, keys[i as usize]); + assert_eq!(line.amount, items.get(i).unwrap().amount); + assert!(line.price_paid > 0); + assert_eq!(line.price_paid, q.estimated_cost); + sum += line.price_paid; + } + assert_eq!(event.total_cost, sum); + assert_eq!(event.total_cost, quote.total_cost); +} + +#[test] +fn full_bundle_reverts_when_an_item_exceeds_max_price() { + let env = test_env_with_auths(); + let client = setup(&env); + let keys = creators(&env, &client, 3); + let buyer = Address::generate(&env); + + let probe = Vec::from_array(&env, [item(&keys[2], 3, LOOSE_MAX)]); + let cost_of_last = client.get_bundle_quote(&probe).total_cost; + + // Items 0 and 1 are fine; item 2's ceiling is one unit below its cost. + let items = Vec::from_array( + &env, + [ + item(&keys[0], 2, LOOSE_MAX), + item(&keys[1], 1, LOOSE_MAX), + item(&keys[2], 3, cost_of_last - 1), + ], + ); + let result = client.try_bundle_buy(&buyer, &items); + assert_eq!(result, Err(Ok(ContractError::SlippageExceeded))); + + // Nothing from the earlier, individually valid items may persist. + for key in &keys { + assert_eq!(client.get_key_balance(key, &buyer), 0); + assert_eq!(client.get_total_key_supply(key), 0); + } + let bundle_events = env + .events() + .all() + .iter() + .filter(|(_, topics, _)| { + topics + .get(events::TOPIC_EVENT_NAME_INDEX) + .map(|v| { + let name: Symbol = v.into_val(&env); + name == events::BUNDLE_EXECUTED_EVENT_NAME + }) + .unwrap_or(false) + }) + .count(); + assert_eq!(bundle_events, 0); +} + +#[test] +fn full_bundle_reverts_when_a_later_item_fails_for_another_reason() { + let env = test_env_with_auths(); + let client = setup(&env); + let keys = creators(&env, &client, 2); + let buyer = Address::generate(&env); + let unregistered = Address::generate(&env); + + let items = Vec::from_array( + &env, + [ + item(&keys[0], 2, LOOSE_MAX), + item(&keys[1], 1, LOOSE_MAX), + item(&unregistered, 1, LOOSE_MAX), + ], + ); + let result = client.try_bundle_buy(&buyer, &items); + assert_eq!(result, Err(Ok(ContractError::NotRegistered))); + + for key in &keys { + assert_eq!(client.get_key_balance(key, &buyer), 0); + assert_eq!(client.get_total_key_supply(key), 0); + } +} + +#[test] +fn full_bundle_reverts_when_an_item_hits_its_supply_cap() { + let env = test_env_with_auths(); + let client = setup(&env); + let keys = creators(&env, &client, 2); + let buyer = Address::generate(&env); + + // Cap key 1 at a single key, then ask for two: the item cannot be filled. + client.set_supply_cap(&keys[1], &1u32); + + let items = Vec::from_array( + &env, + [item(&keys[0], 1, LOOSE_MAX), item(&keys[1], 2, LOOSE_MAX)], + ); + let result = client.try_bundle_buy(&buyer, &items); + assert_eq!(result, Err(Ok(ContractError::BundleItemUnfilled))); + assert_eq!(client.get_total_key_supply(&keys[0]), 0); + assert_eq!(client.get_total_key_supply(&keys[1]), 0); +} + +#[test] +fn quote_matches_execution_on_curve_that_moves_between_items() { + let env = test_env_with_auths(); + let client = setup(&env); + let keys = creators(&env, &client, 3); + let buyer = Address::generate(&env); + let other = Address::generate(&env); + + // Pre-seed different supplies so each item starts at a different price. + client.bundle_buy( + &other, + &Vec::from_array( + &env, + [item(&keys[0], 4, LOOSE_MAX), item(&keys[2], 7, LOOSE_MAX)], + ), + ); + + let items = Vec::from_array( + &env, + [ + item(&keys[0], 3, LOOSE_MAX), + item(&keys[1], 5, LOOSE_MAX), + item(&keys[2], 2, LOOSE_MAX), + ], + ); + let quote = client.get_bundle_quote(&items); + assert_eq!(quote.items.len(), 3); + + let receipt = client.bundle_buy(&buyer, &items); + assert_eq!(receipt.items.len(), 3); + let mut total = 0i128; + for i in 0..3u32 { + let q = quote.items.get(i).unwrap(); + let r = receipt.items.get(i).unwrap(); + assert_eq!(q.key_id, r.key_id); + assert_eq!(q.amount, r.amount); + assert_eq!(q.estimated_cost, r.price_paid, "item {i} estimate"); + total += q.estimated_cost; + } + assert_eq!(quote.total_cost, total); + assert_eq!(quote.total_cost, receipt.total_cost); +} + +#[test] +fn quote_reports_price_impact_per_item() { + let env = test_env_with_auths(); + let client = setup(&env); + let keys = creators(&env, &client, 2); + + let items = Vec::from_array( + &env, + [item(&keys[0], 1, LOOSE_MAX), item(&keys[1], 6, LOOSE_MAX)], + ); + let quote = client.get_bundle_quote(&items); + + for i in 0..2u32 { + let q = quote.items.get(i).unwrap(); + let before = client.query_price(&q.key_id, &0u64); + let after = client.query_price(&q.key_id, &(q.amount as u64)); + let expected = if before > 0 && after > before { + (((after - before) as u128) * 10_000 / (before as u128)) as u32 + } else { + 0 + }; + assert_eq!(q.price_impact_bps, expected); + } + // Buying more keys moves the price at least as much. + assert!( + quote.items.get(1).unwrap().price_impact_bps + >= quote.items.get(0).unwrap().price_impact_bps + ); +} + +#[test] +fn bundle_of_exactly_ten_items_is_accepted() { + let env = test_env_with_auths(); + let client = setup(&env); + let keys = creators(&env, &client, MAX_BUNDLE_ITEMS); + let buyer = Address::generate(&env); + + let mut items = Vec::new(&env); + for key in &keys { + items.push_back(item(key, 1, LOOSE_MAX)); + } + assert_eq!(client.get_bundle_quote(&items).items.len(), 10); + let receipt = client.bundle_buy(&buyer, &items); + assert_eq!(receipt.items.len(), 10); + for key in &keys { + assert_eq!(client.get_key_balance(key, &buyer), 1); + } +} + +#[test] +fn bundle_with_more_than_ten_items_is_rejected() { + let env = test_env_with_auths(); + let client = setup(&env); + let keys = creators(&env, &client, MAX_BUNDLE_ITEMS + 1); + let buyer = Address::generate(&env); + + let mut items = Vec::new(&env); + for key in &keys { + items.push_back(item(key, 1, LOOSE_MAX)); + } + assert_eq!(items.len(), 11); + + assert_eq!( + client.try_bundle_buy(&buyer, &items), + Err(Ok(ContractError::BundleSizeExceeded)) + ); + assert_eq!( + client.try_get_bundle_quote(&items), + Err(Ok(ContractError::BundleSizeExceeded)) + ); + for key in &keys { + assert_eq!(client.get_total_key_supply(key), 0); + } +}