From dd12ad959cb7bcb7c5ddd698f294dd37da5794b3 Mon Sep 17 00:00:00 2001 From: Pi Lanningham Date: Sun, 7 Apr 2024 01:03:12 -0400 Subject: [PATCH] Update the swap calculation to be more fair For example, consider we have a low-divisiblity pool, like XDIAMOND, with 20k ADA and 50 XDIAMOND If the user offers 799 ADA, they could get ~1.9 XDIAMOND... except that because of rounding, and because XDIAMOND isn't divisible, we get only 1 This means the user paid almost twice as much as they could have. To ensure a fairer pricing for the user, we 1) allow less than `offer` tokens to be actually given to the pool 2) ensure that this given amount still gives the same `takes` as the `offer` 3) ensure that if we had given one less, we would have gotten less! Thus, this forces the scooper to build a transaction where the user gives 408_367_450 into the pool, and the remaining 390_632_550 is returned to them. --- lib/calculation/swap.ak | 99 +++++++++++++++++++++++----- validators/oracle.ak | 4 +- validators/tests/pool.ak | 137 ++++++++++++++++++++++++++++++++------- 3 files changed, 196 insertions(+), 44 deletions(-) diff --git a/lib/calculation/swap.ak b/lib/calculation/swap.ak index 9adbd47..15131f7 100644 --- a/lib/calculation/swap.ak +++ b/lib/calculation/swap.ak @@ -29,19 +29,79 @@ pub fn swap_takes( // The ADA fee to deduct as part of the protocol fee actual_protocol_fee: Int, // The amount of the given token that the user has offered to swap - order_give: Int, + order_offer: Int, // The input value on the order UTXO, to calculate change input_value: Value, -) -> (Int, Value) { + // The output value on the order UTXO, to calculate the real order give amount + output_value: Value, +) -> (Int, Int, Value) { // We compute the AMM formula, but // algebraically rearranged to minimize rounding error + // Also, we want to ensure that: + // - The full order is filled (the user gets the most `takes` for the tokens they've offered) + // - The user couldnot have gotten those `takes` any cheaper + // + // For example, consider a pool with 50 XDIAMOND and 20,000 ADA + // If a user offers 799 ADA for the swap, the AMM formula calculates roughly 1.9 XDIAMOND + // We have to round that down, because we can't give out fractional units, and rounding up would allow the pool to be drained + // But, 799 is dramatically overpaying for 1 XDIAMOND, as they could have gotten 1 XDIAMOND with as little as 409.391440 ADA. + // + // To ensure this, we let the scooper calculate the right amount off-chain, then verify it on-chain + let difference = 10000 - fees_per_10_thousand - // Compute the amount of token received - let takes = - pool_take * order_give * difference / ( - pool_give * 10000 + order_give * difference + // Check how much the user is actually giving up, by comparing the difference between the input and output values + let order_give = value.quantity_of(input_value, give_policy_id, give_asset_name) - value.quantity_of(output_value, give_policy_id, give_asset_name) + let order_give = if give_policy_id == ada_policy_id { order_give - actual_protocol_fee } else { order_give } + // TODO: is this neccesary? or will one of the checks below *always* fail? + expect order_give > 0 + + // The AMM formula would be + // let takes = + // pool_take * difference * order_give / ( + // pool_give * 10000 + order_give * difference + // ) + // Since we're going to calculate it a few times, we pull out some common terms for reuse + let pool_take_times_difference = pool_take * difference + let pool_give_10k = pool_give * 10000 + + // Now, we calculate the amount of token the user receives based on the amount they actually gave + let give_takes_numerator = pool_take_times_difference * order_give + let give_takes_denominator = pool_give_10k + order_give * difference + let give_takes = give_takes_numerator / give_takes_denominator + // Make sure they're getting at least one token! + expect give_takes > 0 + + // If the amount actually given by the user is less than the amount offered by the user, + // then we need to make sure they would have received the same amount, and this amount is just + // more efficient. + // Otherwise, they have to be equal (i.e. the scooper can not force the user to give up more than they offered) + expect if order_give < order_offer { + let offer_takes_numerator = pool_take_times_difference * order_offer + let offer_takes_denominator = pool_give_10k + order_offer * difference + let offer_takes = offer_takes_numerator / offer_takes_denominator + offer_takes == give_takes + } else { + order_give == order_offer + } + + // We need to make sure that the user is getting the most efficient swap + // So we check what they would receive with one less unit of the token they're giving + // This would be + // + // let takes = + // pool_take * difference * (order_give - 1) / ( + // pool_give * 10000 + (order_give - 1) * difference + // ) + // + // We can expand this out, algebraically, and simplify to reuse things we've already calculated + // + let one_less = + (give_takes_numerator - pool_take_times_difference) / ( + give_takes_denominator - difference ) + // And that *must* give strictly less takes; this means that the user is getting the most efficient order possible + expect one_less < give_takes // And then compute the desired output // which is the input value, minus the tokens being swapped, minus the protocol fee @@ -50,9 +110,9 @@ pub fn swap_takes( input_value |> value.add(give_policy_id, give_asset_name, -order_give) |> value.add(ada_policy_id, ada_asset_name, -actual_protocol_fee) - |> value.add(take_policy_id, take_asset_name, takes) + |> value.add(take_policy_id, take_asset_name, give_takes) - (takes, out_value) + (give_takes, order_give, out_value) } /// Calculate the new pool state after performing a swap, and validate that the output is correct according to the order @@ -74,6 +134,7 @@ pub fn do_swap( output: Output, ) -> PoolState { let Output { value: input_value, .. } = input_utxo + let Output { value: output_value, address: output_address, datum: output_datum, .. } = output // Destructure the pool state // TODO: it'd make the code nightmarish, but things would be way more efficient to just always pass these values destructured as parameters... let (offer_policy_id, offer_asset_name, offer_amt) = offer @@ -91,15 +152,15 @@ pub fn do_swap( expect when destination is { Fixed { address, datum } -> { and { - output.address == address, - output.datum == datum + output_address == address, + output_datum == datum } } Self -> { let Output { address: input_address, datum: input_datum, .. } = input_utxo and { - output.address == input_address, - output.datum == input_datum + output_address == input_address, + output_datum == input_datum } } } @@ -112,7 +173,7 @@ pub fn do_swap( expect min_received.1st == b_policy_id expect min_received.2nd == b_asset_name // Compute the actual takes / change - let (takes, out_value) = + let (takes, gives, out_value) = swap_takes( offer_policy_id, offer_asset_name, @@ -124,16 +185,17 @@ pub fn do_swap( actual_protocol_fee, offer_amt, input_value, + output_value, ) // Make sure the correct value (including change) carries through to the output - expect output.value == out_value + expect output_value == out_value // And check that the min_received (the lowest amount of tokens the user is willing to receive, aka a limit price) // is satisfied expect takes >= min_received.3rd PoolState { - quantity_a: (a_policy_id, a_asset_name, a_amt + offer_amt), + quantity_a: (a_policy_id, a_asset_name, a_amt + gives), quantity_b: (b_policy_id, b_asset_name, b_amt - takes), quantity_lp, } @@ -142,7 +204,7 @@ pub fn do_swap( // it's easy to make a mistake when copy-pasting. If there's an easier way to express this symmetry that would be good too expect min_received.1st == a_policy_id expect min_received.2nd == a_asset_name - let (takes, out_value) = + let (takes, gives, out_value) = swap_takes( offer_policy_id, offer_asset_name, @@ -154,16 +216,17 @@ pub fn do_swap( actual_protocol_fee, offer_amt, input_value, + output_value ) // Make sure the correct value (including change) carries through to the output - expect output.value == out_value + expect output_value == out_value // Check that mintakes is satisfied expect takes >= min_received.3rd PoolState { quantity_a: (a_policy_id, a_asset_name, a_amt - takes), - quantity_b: (b_policy_id, b_asset_name, b_amt + offer_amt), + quantity_b: (b_policy_id, b_asset_name, b_amt + gives), quantity_lp, } } else { diff --git a/validators/oracle.ak b/validators/oracle.ak index c3164ce..523af93 100644 --- a/validators/oracle.ak +++ b/validators/oracle.ak @@ -222,7 +222,9 @@ fn mint_oracle( identifier: pool_id, assets: ((#"", #""), (rberry_policy_id, rberry_token_name)), circulating_lp: 1_000_000_000, - fees_per_10_thousand: (5, 5), + bid_fees_per_10_thousand: (5, 5), + ask_fees_per_10_thousand: (5, 5), + fee_manager: None, market_open: 0, fee_finalized: 0, protocol_fees: 2_000_000, diff --git a/validators/tests/pool.ak b/validators/tests/pool.ak index ab103ef..ab04a47 100644 --- a/validators/tests/pool.ak +++ b/validators/tests/pool.ak @@ -20,7 +20,7 @@ use tests/examples/ex_settings.{mk_valid_settings_input, mk_valid_settings_datum use tests/examples/ex_shared.{ mk_output_reference, mk_tx_hash, script_address, wallet_address, } -use types/order.{Deposit, Destination, Fixed, Self, OrderDatum, Swap} +use types/order.{Deposit, Destination, Fixed, Self, OrderDatum, Order, Swap} use types/pool.{ PoolMintRedeemer, CreatePool, PoolDatum, PoolScoop, WithdrawFees, UpdatePoolFees, } @@ -32,8 +32,12 @@ use tests/constants type ScoopTestOptions { - edit_order_1_value: Option, - edit_order_2_value: Option, + edit_order_1_in_value: Option, + edit_order_2_in_value: Option, + edit_order_1_out_value: Option, + edit_order_2_out_value: Option, + edit_order_1_details: Option, + edit_order_2_details: Option, edit_order_intended_destination: Option, edit_order_actual_destination: Option, edit_fee: Option, @@ -41,6 +45,7 @@ type ScoopTestOptions { edit_fee_admin: Option>, edit_withdrawals: Option>, edit_pool_input_address: Option
, + edit_pool_input_value: Option, edit_pool_output_address: Option
, edit_pool_output_value: Option, edit_pool_output_datum: Option, @@ -49,8 +54,12 @@ type ScoopTestOptions { fn default_scoop_test_options() -> ScoopTestOptions { ScoopTestOptions { - edit_order_1_value: None, - edit_order_2_value: None, + edit_order_1_in_value: None, + edit_order_2_in_value: None, + edit_order_1_out_value: None, + edit_order_2_out_value: None, + edit_order_1_details: None, + edit_order_2_details: None, edit_order_intended_destination: None, edit_order_actual_destination: None, edit_fee: None, @@ -58,6 +67,7 @@ fn default_scoop_test_options() -> ScoopTestOptions { edit_fee_admin: None, edit_withdrawals: None, edit_pool_input_address: None, + edit_pool_input_value: None, edit_pool_output_address: None, edit_pool_output_value: None, edit_pool_output_datum: None, @@ -75,6 +85,59 @@ test ok_scoop_swap_deposit() { scoop_swap_deposit(options) } +test ok_scoop_swap_with_surplus() { + let options = ScoopTestOptions { + ..default_scoop_test_options(), + edit_order_1_in_value: Some( + value.from_lovelace(4_500_000 + 20_000_000) + ), + edit_order_1_out_value: Some( + value.from_lovelace(2_000_000 + 10_000_000) + |> value.add(constants.rberry_policy, constants.rberry_asset_name, 9_896_088), + ) + } + scoop(options) +} +test ok_scoop_swap_with_over_rounding() { + let pool_nft_name = shared.pool_nft_name(constants.pool_ident) + let amt_1 = 408_367_450 + let amt_2 = 425_387_016 + let options = ScoopTestOptions { + ..default_scoop_test_options(), + edit_pool_input_value: Some( + value.from_lovelace(20_000_000_000 + 2_000_000) + |> value.add(constants.rberry_policy, constants.rberry_asset_name, 50) + |> value.add(constants.pool_script_hash, pool_nft_name, 1) + ), + edit_pool_output_value: Some( + value.from_lovelace(amt_1 + amt_2 + 20_000_000_000 + 2_000_000 + 2_500_000 + 2_500_000) + |> value.add(constants.rberry_policy, constants.rberry_asset_name, 48) + |> value.add(constants.pool_script_hash, pool_nft_name, 1) + ), + edit_order_1_details: Some( + Swap((ada_policy_id, ada_asset_name, 799_000_000), (constants.rberry_policy, constants.rberry_asset_name, 0)), + ), + edit_order_1_in_value: Some( + value.from_lovelace(4_500_000 + 799_000_000) + ), + edit_order_1_out_value: Some( + value.from_lovelace(2_000_000 + 799_000_000 - amt_1) + |> value.add(constants.rberry_policy, constants.rberry_asset_name, 1), + ), + edit_order_2_details: Some( + Swap((ada_policy_id, ada_asset_name, 799_000_000), (constants.rberry_policy, constants.rberry_asset_name, 0)), + ), + edit_order_2_in_value: Some( + value.from_lovelace(4_500_000 + 799_000_000) + ), + edit_order_2_out_value: Some( + value.from_lovelace(2_000_000 + 799_000_000 - amt_2) + |> value.add(constants.rberry_policy, constants.rberry_asset_name, 1), + ), + } + scoop(options) +} + test scoop_bad_destination() fail { let burn_addr = wallet_address(#"12000000000000000000000000000000000000000000000000000000") @@ -90,11 +153,11 @@ test scoop_payouts_swapped() fail { let options = ScoopTestOptions { ..default_scoop_test_options(), - edit_order_1_value: Some( + edit_order_1_out_value: Some( value.from_lovelace(2_000_000) |> value.add(constants.rberry_policy, constants.rberry_asset_name, 9_702_095), ), - edit_order_2_value: Some( + edit_order_2_out_value: Some( value.from_lovelace(2_000_000) |> value.add(constants.rberry_policy, constants.rberry_asset_name, 9_896_088), ), @@ -118,11 +181,11 @@ test scoop_high_swap_fees() { ScoopTestOptions { ..default_scoop_test_options(), edit_swap_fees: Some(((swap_fee, swap_fee), (swap_fee, swap_fee))), - edit_order_1_value: Some( + edit_order_1_out_value: Some( value.from_lovelace(2_000_000) |> value.add(constants.rberry_policy, constants.rberry_asset_name, 9_802_950), ), - edit_order_2_value: Some( + edit_order_2_out_value: Some( value.from_lovelace(2_000_000) |> value.add(constants.rberry_policy, constants.rberry_asset_name, 9_611_678), ), @@ -253,23 +316,28 @@ fn scoop(options: ScoopTestOptions) { output_reference: mk_output_reference(0), output: Output { address: option.or_else(options.edit_pool_input_address, pool_address), - value: value.from_lovelace(1_000_000_000 + 2_000_000) - |> value.add(constants.rberry_policy, constants.rberry_asset_name, 1_000_000_000) - |> value.add(constants.pool_script_hash, pool_nft_name, 1), + value: option.or_else( + options.edit_pool_input_value, + value.from_lovelace(1_000_000_000 + 2_000_000) + |> value.add(constants.rberry_policy, constants.rberry_asset_name, 1_000_000_000) + |> value.add(constants.pool_script_hash, pool_nft_name, 1), + ), datum: InlineDatum(pool_datum), reference_script: None, }, } let dest = option.or_else(options.edit_order_intended_destination, Fixed { address: user_addr, datum: NoDatum }) - let swap = - Swap((ada_policy_id, ada_asset_name, 10_000_000), (constants.rberry_policy, constants.rberry_asset_name, 0)) - let order_datum = + let swap_1 = option.or_else( + options.edit_order_1_details, + Swap((ada_policy_id, ada_asset_name, 10_000_000), (constants.rberry_policy, constants.rberry_asset_name, 0)), + ) + let order_1_datum = OrderDatum { pool_ident: None, owner, max_protocol_fee: 2_500_000, destination: dest, - details: swap, + details: swap_1, extension: builtin.i_data(0), } let order_address = script_address(constants.order_script_hash) @@ -278,18 +346,37 @@ fn scoop(options: ScoopTestOptions) { output_reference: mk_output_reference(2), output: Output { address: order_address, - value: value.from_lovelace(4_500_000 + 10_000_000), - datum: InlineDatum(order_datum), + value: option.or_else( + options.edit_order_1_in_value, + value.from_lovelace(4_500_000 + 10_000_000) + ), + datum: InlineDatum(order_1_datum), reference_script: None, }, } + let swap_2 = option.or_else( + options.edit_order_2_details, + Swap((ada_policy_id, ada_asset_name, 10_000_000), (constants.rberry_policy, constants.rberry_asset_name, 0)), + ) + let order_2_datum = + OrderDatum { + pool_ident: None, + owner, + max_protocol_fee: 2_500_000, + destination: dest, + details: swap_2, + extension: builtin.i_data(0), + } let order2_in = Input { output_reference: mk_output_reference(3), output: Output { address: order_address, - value: value.from_lovelace(4_500_000 + 10_000_000), - datum: InlineDatum(order_datum), + value: option.or_else( + options.edit_order_2_in_value, + value.from_lovelace(4_500_000 + 10_000_000) + ), + datum: InlineDatum(order_2_datum), reference_script: None, }, } @@ -305,14 +392,14 @@ fn scoop(options: ScoopTestOptions) { } let (order1_out_addr, order1_out_datum) = when options.edit_order_actual_destination is { Some(Fixed(dest, datum)) -> (dest, datum) - Some(Self) -> (order_address, InlineDatum(order_datum)) + Some(Self) -> (order_address, InlineDatum(order_1_datum)) None -> (user_addr, NoDatum) } let order1_out = Output { address: order1_out_addr, value: option.or_else( - options.edit_order_1_value, + options.edit_order_1_out_value, value.from_lovelace(2_000_000) |> value.add(constants.rberry_policy, constants.rberry_asset_name, 9_896_088), ), @@ -322,14 +409,14 @@ fn scoop(options: ScoopTestOptions) { // TODO: manage separately? let (order2_out_addr, order2_out_datum) = when options.edit_order_actual_destination is { Some(Fixed(dest, datum)) -> (dest, datum) - Some(Self) -> (order_address, InlineDatum(order_datum)) + Some(Self) -> (order_address, InlineDatum(order_2_datum)) None -> (user_addr, NoDatum) } let order2_out = Output { address: order2_out_addr, value: option.or_else( - options.edit_order_2_value, + options.edit_order_2_out_value, value.from_lovelace(2_000_000) |> value.add(constants.rberry_policy, constants.rberry_asset_name, 9_702_095), ), @@ -492,7 +579,7 @@ fn scoop_swap_deposit(options: ScoopTestOptions) { } let order1_out = Output { address: order1_out_addr, - value: option.or_else(options.edit_order_1_value, + value: option.or_else(options.edit_order_1_out_value, value.from_lovelace(2_000_000) |> value.add(constants.rberry_policy, constants.rberry_asset_name, 9_896_088)), datum: order1_out_datum,