From 7a92750d15b3660c08eb69b6155f2d491af7f2c4 Mon Sep 17 00:00:00 2001 From: Pi Lanningham Date: Sun, 11 Feb 2024 15:15:56 -0500 Subject: [PATCH] Add a special 'Self' destination variant We want to allow recurring automated strategies that can execute in multiple stages. The idea behind this was to allow setting the destination back to the order script with a "strategy" order details. However, this overlooks the fact that this would require setting a destination datum hash. This means that our order executions would have a finite depth. So, this commit adds a new variant to the Destination, which enforces that the results of the swap are paid back to the *same address, with the same datum*. Thus, we could construct the order datum: ``` OrderDatum { pool_ident: ..., owner: ..., max_protocol_fee: ..., destination: Self, details: Strategy { ... }, extension: ... } ``` and the output order datum will be... the same thing. Some notes: - We make sure it's the *second* variant, which should mean that it doesn't change the offchain code - This doesn't make a whole lot of sense for any other variant; should we only allow it for strategies? Unless the auditors come up with a problem, I don't see a reason to restrict it. --- lib/calculation/deposit.ak | 38 ++++++++++++++++++++++------- lib/calculation/donation.ak | 37 +++++++++++++++++++++------- lib/calculation/swap.ak | 44 ++++++++++++++++++++++++++-------- lib/calculation/withdrawal.ak | 40 +++++++++++++++++++++++-------- lib/tests/aiken/deposit.ak | 14 +++++++---- lib/tests/examples/ex_order.ak | 4 ++-- lib/types/order.ak | 15 ++++++++---- 7 files changed, 143 insertions(+), 49 deletions(-) diff --git a/lib/calculation/deposit.ak b/lib/calculation/deposit.ak index c429191..3de1c82 100644 --- a/lib/calculation/deposit.ak +++ b/lib/calculation/deposit.ak @@ -1,11 +1,11 @@ use aiken/math -use aiken/transaction.{NoDatum, Output} +use aiken/transaction.{NoDatum, Output, InlineDatum} use aiken/transaction/credential.{Address, VerificationKeyCredential} -use aiken/transaction/value.{Value, ada_policy_id, ada_asset_name} +use aiken/transaction/value.{ada_policy_id, ada_asset_name} use calculation/shared.{PoolState} as calc_shared use sundae/multisig use shared.{SingletonValue} -use types/order.{Destination, OrderDatum} +use types/order.{Destination, Fixed, Self, OrderDatum} /// Calculate the result of depositing some amount of tokens into the pool /// @@ -14,13 +14,14 @@ use types/order.{Destination, OrderDatum} /// pub fn do_deposit( pool_state: PoolState, - input_value: Value, + input_utxo: Output, assets: (SingletonValue, SingletonValue), destination: Destination, actual_protocol_fee: Int, output: Output, ) -> PoolState { let (asset_a, asset_b) = assets + let Output { address: input_address, datum: input_datum, value: input_value, .. } = input_utxo // Policy ID and token name of the assets must match the pool, otherwise someone // could load the pool with junk tokens and freeze the pool. @@ -107,10 +108,23 @@ pub fn do_deposit( ) // Make sure we're paying the result to the correct destination (both the address and the datum), - // with the correct amount - expect output.address == destination.address - expect output.datum == destination.datum + // with the correct amount; In the special case where Datum is "Self" (for example for a repeating strategy) + // use the input datum for validation expect output.value == out_value + expect when destination is { + Fixed { address, datum } -> { + and { + output.address == address, + output.datum == datum + } + } + Self -> { + and { + output.address == input_address, + output.datum == input_datum + } + } + } // And construct the final pool state PoolState { @@ -158,7 +172,7 @@ test deposit_test() { #"6af53ff4f054348ad825c692dd9db8f1760a8e0eacf9af9f99306513", ), max_protocol_fee: 2_500_000, - destination: Destination { + destination: Fixed { address: addr, datum: NoDatum, }, @@ -174,7 +188,13 @@ test deposit_test() { datum: NoDatum, reference_script: None, } - let final_pool_state = do_deposit(pool_state, input_value, assets, order.destination, 2_500_000, output) + let input = Output { + address: addr, + value: input_value, + datum: InlineDatum(order), + reference_script: None, + } + let final_pool_state = do_deposit(pool_state, input, assets, order.destination, 2_500_000, output) expect final_pool_state.quantity_a.3rd == 1_010_000_000 expect final_pool_state.quantity_b.3rd == 1_010_000_000 True diff --git a/lib/calculation/donation.ak b/lib/calculation/donation.ak index a1d7b4e..357ba2c 100644 --- a/lib/calculation/donation.ak +++ b/lib/calculation/donation.ak @@ -1,10 +1,10 @@ -use aiken/transaction.{NoDatum, Output} +use aiken/transaction.{NoDatum, InlineDatum, Output} use aiken/transaction/credential.{Address, VerificationKeyCredential} use aiken/transaction/value.{Value, ada_policy_id, ada_asset_name} use calculation/shared.{PoolState} as calc_shared use shared.{SingletonValue} use sundae/multisig -use types/order.{Destination, OrderDatum} +use types/order.{Destination, Fixed, Self, OrderDatum} /// A donation describes an amount of assets to deposit into the pool, receiving nothing in return (except for the extra change on the UTXO). /// Because every LP token holder has an entitlement to a percentage of the assets in the pool, the donation is distributed to all LP token holders @@ -17,7 +17,7 @@ pub fn do_donation( /// The pool state as a result of processing all orders before this one pool_state: PoolState, /// The total quantity of tokens on this particular input - input_value: Value, + input_utxo: Output, /// The amounts of each asset being donated assets: (SingletonValue, SingletonValue), /// The destination (address + datum) that any *change* should be sent @@ -29,6 +29,7 @@ pub fn do_donation( /// If there is no change leftover, this output is ignored and used for the next order output: Output, ) -> (PoolState, Bool) { + let Output { value: input_value, address: input_address, datum: input_datum, .. } = input_utxo // Make sure we're actually donating the pool assets; this is to prevent setting // poolIdent to None, and then filling the pool UTXO with garbage tokens and eventually locking it expect assets.1st.1st == pool_state.quantity_a.1st @@ -48,10 +49,22 @@ pub fn do_donation( // is so that we can do a few expects without having to return a value right away expect or { !has_remainder, - and { - output.address == destination.address, - output.datum == destination.datum, - output.value == remainder, + // Make sure we're paying the result to the correct destination (both the address and the datum), + // with the correct amount; In the special case where Datum is "Self" (for example for a repeating strategy) + // use the input datum for validation + when destination is { + Fixed { address, datum } -> { + and { + output.address == address, + output.datum == datum + } + } + Self -> { + and { + output.address == input_address, + output.datum == input_datum + } + } }, } @@ -109,7 +122,7 @@ test donation() { #"6af53ff4f054348ad825c692dd9db8f1760a8e0eacf9af9f99306513", ), max_protocol_fee: 2_500_000, - destination: Destination { address: addr, datum: NoDatum }, + destination: Fixed { address: addr, datum: NoDatum }, details: order.Donation { assets: assets, }, @@ -123,8 +136,14 @@ test donation() { datum: NoDatum, reference_script: None, } + let input = Output { + address: addr, + value: input_value, + datum: InlineDatum(order), + reference_script: None, + } let (final_pool_state, has_remainder) = - do_donation(pool_state, input_value, assets, order.destination, 2_500_000, output) + do_donation(pool_state, input, assets, order.destination, 2_500_000, output) expect !has_remainder expect final_pool_state.quantity_a.3rd == 1_001_000_000 expect final_pool_state.quantity_b.3rd == 1_001_000_000 diff --git a/lib/calculation/swap.ak b/lib/calculation/swap.ak index ed6fdde..f912649 100644 --- a/lib/calculation/swap.ak +++ b/lib/calculation/swap.ak @@ -1,4 +1,4 @@ -use aiken/transaction.{NoDatum, Output} +use aiken/transaction.{NoDatum, InlineDatum, Output} use aiken/transaction/credential.{Address, VerificationKeyCredential} use aiken/transaction/value.{ AssetName, PolicyId, Value, ada_asset_name, ada_policy_id, @@ -6,7 +6,7 @@ use aiken/transaction/value.{ use calculation/shared.{PoolState} as calc_shared use shared.{SingletonValue} use sundae/multisig -use types/order.{Destination, OrderDatum} +use types/order.{Destination, Fixed, Self, OrderDatum} /// Compute the amount of token a swap yields; returns the amount of token received, and the remainder to be sent to the user /// @@ -60,7 +60,7 @@ pub fn swap_takes( pub fn do_swap( pool_state: PoolState, /// The value coming in from the UTXO with the order - input_value: Value, + input_utxo: Output, /// Where the results of the swap need to be paid destination: Destination, /// The liquidity provider fee to charge @@ -73,6 +73,7 @@ pub fn do_swap( /// The output to compare against output: Output, ) -> PoolState { + let Output { address: input_address, datum: input_datum, value: input_value, .. } = input_utxo // 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 @@ -84,6 +85,24 @@ pub fn do_swap( let (a_policy_id, a_asset_name, a_amt) = quantity_a let (b_policy_id, b_asset_name, b_amt) = quantity_b + // Make sure we're paying the result to the correct destination (both the address and the datum), + // with the correct amount; In the special case where Datum is "Self" (for example for a repeating strategy) + // use the input datum for validation + expect when destination is { + Fixed { address, datum } -> { + and { + output.address == address, + output.datum == datum + } + } + Self -> { + and { + output.address == input_address, + output.datum == input_datum + } + } + } + // There are two symmetric cases, depending on whether you're giving order A or order B // If there's a clever way to write this as one branch it might be clearer to read if offer_policy_id == a_policy_id && offer_asset_name == a_asset_name { @@ -106,10 +125,9 @@ pub fn do_swap( input_value, ) - // Check that the output has the correct destination and value - expect output.address == destination.address - expect output.datum == destination.datum + // Make sure the correct value (including change) carries through to the output 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 @@ -137,9 +155,9 @@ pub fn do_swap( input_value, ) - expect output.address == destination.address - expect output.datum == destination.datum + // Make sure the correct value (including change) carries through to the output expect output.value == out_value + // Check that mintakes is satisfied expect takes >= min_received.3rd PoolState { @@ -179,7 +197,7 @@ test swap_mintakes_too_high() fail { #"6af53ff4f054348ad825c692dd9db8f1760a8e0eacf9af9f99306513", ), max_protocol_fee: 2_500_000, - destination: Destination { + destination: Fixed { address: addr, datum: NoDatum, }, @@ -193,7 +211,13 @@ test swap_mintakes_too_high() fail { datum: NoDatum, reference_script: None, } - let final_pool_state = do_swap(pool_state, input_value, order.destination, 5, 2_500_000, swap_offer, swap_min_received, output) + let input = Output { + address: addr, + value: input_value, + datum: InlineDatum(order), + reference_script: None, + } + let final_pool_state = do_swap(pool_state, input, order.destination, 5, 2_500_000, swap_offer, swap_min_received, output) expect final_pool_state.quantity_a.3rd == 1_000_000_000 + 10_000_000 expect final_pool_state.quantity_b.3rd == 1_000_000_000 - 9_896_088 True diff --git a/lib/calculation/withdrawal.ak b/lib/calculation/withdrawal.ak index 8a9cc4d..a50e204 100644 --- a/lib/calculation/withdrawal.ak +++ b/lib/calculation/withdrawal.ak @@ -1,12 +1,12 @@ use aiken/option use aiken/math -use aiken/transaction.{NoDatum, Output} +use aiken/transaction.{NoDatum, InlineDatum, Output} use aiken/transaction/credential.{Address, VerificationKeyCredential} use aiken/transaction/value.{Value, ada_policy_id, ada_asset_name} use calculation/shared.{PoolState} as calc_shared use sundae/multisig use shared.{SingletonValue} -use types/order.{Destination, OrderDatum} +use types/order.{Destination, Fixed, Self, OrderDatum} /// Execute a withdrawal order /// @@ -14,13 +14,14 @@ use types/order.{Destination, OrderDatum} /// of the circulating supply of LP tokens that was burned. pub fn do_withdrawal( pool_state: PoolState, // The interim pool state against which we should calculate the withdrawal - input_value: Value, // The incoming value; may have additional assets on it, for example if executing as part of a larger chain + input_utxo: Output, // The incoming UTXO, useful for returning surplus and handling Self destinations amount: SingletonValue, // The amount to actually withdraw destination: Destination, // The destination that the LP tokens (and change) should be sent to actual_protocol_fee: Int, // The protocol fee to deduct output: Output, // The output to compare the order execution against, to ensure the right quantity was paid out ) -> PoolState { let (lp_policy, lp_asset_name, amount) = amount + let Output { address: input_address, datum: input_datum, value: input_value, .. } = input_utxo // Make sure we're withdrawing from the right pool; // if we were to provide LP tokens for a different pool, you could withdraw funds that weren't yours @@ -61,12 +62,25 @@ pub fn do_withdrawal( withdrawn_b, ) - // Check that the result is paid to the destination with the full value - // Like on other order types, the datum here lets us compose with other protocols - // or do chained orders within Sundae - expect output.address == destination.address - expect output.datum == destination.datum + + // Make sure we're paying the result to the correct destination (both the address and the datum), + // with the correct amount; In the special case where Datum is "Self" (for example for a repeating strategy) + // use the input datum for validation expect output.value == remainder + expect when destination is { + Fixed { address, datum } -> { + and { + output.address == address, + output.datum == datum + } + } + Self -> { + and { + output.address == input_address, + output.datum == input_datum + } + } + } // Return the final pool state PoolState { @@ -153,7 +167,7 @@ fn withdrawal_test(options: WithdrawalTestOptions) { #"6af53ff4f054348ad825c692dd9db8f1760a8e0eacf9af9f99306513", ), max_protocol_fee: 2_500_000, - destination: Destination { + destination: Fixed { address: addr, datum: NoDatum, }, @@ -172,7 +186,13 @@ fn withdrawal_test(options: WithdrawalTestOptions) { datum: NoDatum, reference_script: None, } - let final_pool_state = do_withdrawal(pool_state, input_value, amount, order.destination, 2_500_000, output) + let input = Output { + address: addr, + value: input_value, + datum: InlineDatum(order), + reference_script: None, + } + let final_pool_state = do_withdrawal(pool_state, input, amount, order.destination, 2_500_000, output) expect final_pool_state.quantity_a.3rd == 1_000_000_000 expect final_pool_state.quantity_b.3rd == 1_000_000_000 True diff --git a/lib/tests/aiken/deposit.ak b/lib/tests/aiken/deposit.ak index 830e797..3285e3d 100644 --- a/lib/tests/aiken/deposit.ak +++ b/lib/tests/aiken/deposit.ak @@ -1,11 +1,11 @@ use sundae/multisig use aiken/math -use aiken/transaction.{NoDatum, Output} +use aiken/transaction.{NoDatum, InlineDatum, Output} use aiken/transaction/value use aiken/transaction/credential.{Address, VerificationKeyCredential} use calculation/shared.{PoolState} use calculation/deposit.{do_deposit} -use types/order.{OrderDatum, Destination} +use types/order.{OrderDatum, Destination, Fixed} test deposit_test() { let lp = (#"99999999999999999999999999999999999999999999999999999999", "LP") @@ -48,7 +48,7 @@ fn deposit_test_schema(qa: Int, qb: Int, has_a: Int, has_b: Int, gives_a: Int, g #"6af53ff4f054348ad825c692dd9db8f1760a8e0eacf9af9f99306513", ), max_protocol_fee: 2_500_000, - destination: Destination { + destination: Fixed { address: addr, datum: NoDatum, }, @@ -63,9 +63,15 @@ fn deposit_test_schema(qa: Int, qb: Int, has_a: Int, has_b: Int, gives_a: Int, g datum: NoDatum, reference_script: None, } + let input = Output { + address: addr, + value: input_value, + datum: InlineDatum(order), + reference_script: None, + } let PoolState{..} = do_deposit( pool_state, - input_value, + input, assets, order.destination, 2_500_000, diff --git a/lib/tests/examples/ex_order.ak b/lib/tests/examples/ex_order.ak index d3526e8..562d8b2 100644 --- a/lib/tests/examples/ex_order.ak +++ b/lib/tests/examples/ex_order.ak @@ -1,12 +1,12 @@ use aiken/cbor -use types/order.{Destination, OrderDatum, Swap, Deposit, Withdrawal, Scoop, Cancel} +use types/order.{Destination, Fixed, OrderDatum, Swap, Deposit, Withdrawal, Scoop, Cancel} use aiken/transaction.{NoDatum} use sundae/multisig use tests/examples/ex_shared.{print_example, wallet_address} fn mk_swap() -> OrderDatum { let addr = wallet_address(#"6af53ff4f054348ad825c692dd9db8f1760a8e0eacf9af9f99306513") - let dest = Destination { address: addr, datum: NoDatum } + let dest = Fixed { address: addr, datum: NoDatum } let swap = Swap( (#"", #"", 10000000), diff --git a/lib/types/order.ak b/lib/types/order.ak index b2b9d3e..c4ed945 100644 --- a/lib/types/order.ak +++ b/lib/types/order.ak @@ -32,11 +32,16 @@ pub type OrderDatum { /// The destination where funds are to be sent after the order pub type Destination { - /// The address to attach to the output (such as the users wallet, or another script) - address: Address, - /// The datum to attach to the output; Could be none in typical cases, but could also be used to compose with other protocols, - /// such as paying the results of a swap back to a treasury contract - datum: Datum, + Fixed { + /// The address to attach to the output (such as the users wallet, or another script) + address: Address, + /// The datum to attach to the output; Could be none in typical cases, but could also be used to compose with other protocols, + /// such as paying the results of a swap back to a treasury contract + datum: Datum, + } + /// The result gets paid back to the exact same address and datum it came from; useful for strategies + /// with unbounded legs + Self } /// There are two ways to delegate authorization of a strategy::