From 4f39a7551721b47483faaa7d8781aeaf72ee1617 Mon Sep 17 00:00:00 2001 From: EzePze Date: Fri, 25 Oct 2024 22:57:24 +1100 Subject: [PATCH 1/2] init --- .github/workflows/ci.yml | 37 + .gitignore | 1 + README.md | 3 + aiken.lock | 26 + aiken.toml | 8 + lib/butane/prices.ak | 298 ++++++++ lib/butane/subvalidators/bad_debt.ak | 217 ++++++ lib/butane/subvalidators/cdp_script.ak | 833 ++++++++++++++++++++++ lib/butane/subvalidators/gov_issue.ak | 99 +++ lib/butane/subvalidators/next_compat.ak | 126 ++++ lib/butane/subvalidators/params_script.ak | 572 +++++++++++++++ lib/butane/subvalidators/staking.ak | 131 ++++ lib/butane/subvalidators/treasury.ak | 620 ++++++++++++++++ lib/butane/subvalidators/voided_synth.ak | 70 ++ lib/butane/tests/create_cdp.ak | 297 ++++++++ lib/butane/tests/fuzzers.ak | 87 +++ lib/butane/tests/repay_cdp.ak | 101 +++ lib/butane/tests/utils.ak | 67 ++ lib/butane/types.ak | 495 +++++++++++++ lib/butane/unsafe.ak | 14 + lib/butane/utils.ak | 447 ++++++++++++ validators/leftovers.ak | 40 ++ validators/pointers.ak | 24 + validators/price_feed.ak | 36 + validators/synthetics.ak | 393 ++++++++++ validators/upgradeable.ak | 107 +++ validators/util.ak | 13 + 27 files changed, 5162 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 README.md create mode 100644 aiken.lock create mode 100644 aiken.toml create mode 100644 lib/butane/prices.ak create mode 100644 lib/butane/subvalidators/bad_debt.ak create mode 100644 lib/butane/subvalidators/cdp_script.ak create mode 100644 lib/butane/subvalidators/gov_issue.ak create mode 100644 lib/butane/subvalidators/next_compat.ak create mode 100644 lib/butane/subvalidators/params_script.ak create mode 100644 lib/butane/subvalidators/staking.ak create mode 100644 lib/butane/subvalidators/treasury.ak create mode 100644 lib/butane/subvalidators/voided_synth.ak create mode 100644 lib/butane/tests/create_cdp.ak create mode 100644 lib/butane/tests/fuzzers.ak create mode 100644 lib/butane/tests/repay_cdp.ak create mode 100644 lib/butane/tests/utils.ak create mode 100644 lib/butane/types.ak create mode 100644 lib/butane/unsafe.ak create mode 100644 lib/butane/utils.ak create mode 100644 validators/leftovers.ak create mode 100644 validators/pointers.ak create mode 100644 validators/price_feed.ak create mode 100644 validators/synthetics.ak create mode 100644 validators/upgradeable.ak create mode 100644 validators/util.ak diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..19cd975 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,37 @@ +name: CI + +on: + push: + branches: + - main + pull_request: + +# cancel in-progress runs on new commits to same PR (gitub.event.number) +concurrency: + group: ${{ github.workflow }}-${{ github.event.number || github.sha }} + cancel-in-progress: true + +jobs: + build: + runs-on: ubuntu-latest + continue-on-error: true + steps: + - name: ๐Ÿ“ฅ Checkout repository + uses: actions/checkout@v4 + + - name: Setup Aiken + uses: aiken-lang/setup-aiken@v1 + with: + version: v1.0.29-alpha + + - name: ๐Ÿงช Run fmt check + run: aiken fmt --check + + - name: ๐Ÿงช Run lint + run: aiken check -s -D + + - name: ๐ŸŽ Run build + run: aiken build + + - name: ๐Ÿงช Run test + run: aiken check diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c795b05 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +build \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..22f5909 --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# butane-contracts + +Smart contracts for the Butane Protocol. \ No newline at end of file diff --git a/aiken.lock b/aiken.lock new file mode 100644 index 0000000..d58e948 --- /dev/null +++ b/aiken.lock @@ -0,0 +1,26 @@ +# This file was generated by Aiken +# You typically do not need to edit this file + +[[requirements]] +name = "aiken-lang/stdlib" +version = "1.9.0" +source = "github" + +[[requirements]] +name = "aiken-lang/fuzz" +version = "1.0.0" +source = "github" + +[[packages]] +name = "aiken-lang/stdlib" +version = "1.9.0" +requirements = [] +source = "github" + +[[packages]] +name = "aiken-lang/fuzz" +version = "1.0.0" +requirements = [] +source = "github" + +[etags] diff --git a/aiken.toml b/aiken.toml new file mode 100644 index 0000000..c9af5cf --- /dev/null +++ b/aiken.toml @@ -0,0 +1,8 @@ +name = "mcomp-tech/improved-spork" +version = "0.0.1" +licenses = [] +description = "Butane MVP" +dependencies = [ + { name = "aiken-lang/stdlib", version = "1.9.0", source = "github" }, + { name = "aiken-lang/fuzz", version = "1.0.0", source = "github" }, +] \ No newline at end of file diff --git a/lib/butane/prices.ak b/lib/butane/prices.ak new file mode 100644 index 0000000..a506de1 --- /dev/null +++ b/lib/butane/prices.ak @@ -0,0 +1,298 @@ +use aiken/builtin +use aiken/dict.{Dict} +use aiken/math +use aiken/math/rational.{Rational} +use aiken/transaction/value.{AssetName, PolicyId, Value} +use butane/types +use butane/unsafe + +// Gets the raw collateralization ratio (CR) and health factor (HF) of a CDP +pub fn get_collateral_finances( + p_list: List, + p_dom: Int, + v: Value, + assets: List, + w_list: List, + w_dom: Int, + max_proportions_list: List, + synth_amount: Int, + callback: fn(Rational, Rational) -> a, +) -> a { + let v2: Pairs> = + v |> value.to_dict |> dict.to_pairs + let unweighted_capacity, capacity <- + do_get_borrowing_capacity( + u_value: v2, + debt: synth_amount, + unweighted_capacity: 0, + borrowing_capacity: 0, + collateral_assets: assets, + collateral_prices: p_list, + prices_denom: p_dom, + weights_denom: w_dom, + collateral_weights: w_list, + collateral_max_proportions: max_proportions_list, + callback: _, + ) + let cr = + unsafe.unsome(rational.new(unweighted_capacity, p_dom * synth_amount)) + let hf = unsafe.unsome(rational.new(capacity, synth_amount)) + callback(cr, hf) +} + +pub fn do_get_borrowing_capacity( + u_value: Pairs>, + debt: Int, + unweighted_capacity: Int, + borrowing_capacity: Int, + collateral_assets: List, + collateral_prices: List, + prices_denom: Int, + weights_denom: Int, + collateral_weights: List, + collateral_max_proportions: List, + callback: fn(Int, Int) -> a, +) -> a { + when u_value is { + [Pair(pol, pol_v), ..vs] -> + do_get_borrowing_capacity_2( + pol: pol, + pol_v: pol_v |> dict.to_pairs, + and_finally: do_get_borrowing_capacity(vs, _, _, _, _, _, _, _, _, _, _), + debt: debt, + unweighted_capacity: unweighted_capacity, + borrowing_capacity: borrowing_capacity, + collateral_assets: collateral_assets, + collateral_prices: collateral_prices, + prices_denom: prices_denom, + weights_denom: weights_denom, + collateral_weights: collateral_weights, + collateral_max_proportions: collateral_max_proportions, + callback: callback, + ) + _ -> callback(unweighted_capacity, borrowing_capacity) + } +} + +pub fn do_get_borrowing_capacity_2( + pol: PolicyId, + pol_v: Pairs, + and_finally: fn( + Int, + Int, + Int, + List, + List, + Int, + Int, + List, + List, + fn(Int, Int) -> a, + ) -> + a, + debt: Int, + unweighted_capacity: Int, + borrowing_capacity: Int, + collateral_assets: List, + collateral_prices: List, + prices_denom: Int, + weights_denom: Int, + collateral_weights: List, + collateral_max_proportions: List, + callback: fn(Int, Int) -> a, +) -> a { + when pol_v is { + [Pair(token_name, qty), ..vs] -> + get_asset_price_then( + s: types.AssetClass { policy_id: pol, asset_name: token_name }, + debt: debt, + unweighted_capacity: unweighted_capacity, + borrowing_capacity: borrowing_capacity, + qty: qty, + asset_list: collateral_assets, + price_list: collateral_prices, + prices_denom: prices_denom, + weights_denom: weights_denom, + weights_list: collateral_weights, + max_proportion_list: collateral_max_proportions, + continue_with: do_get_borrowing_capacity_2( + pol, + vs, + and_finally, + _, + _, + _, + _, + _, + _, + _, + _, + _, + _, + ), + callback: callback, + ) + _ -> + and_finally( + debt, + unweighted_capacity, + borrowing_capacity, + collateral_assets, + collateral_prices, + prices_denom, + weights_denom, + collateral_weights, + collateral_max_proportions, + callback, + ) + } +} + +fn get_asset_price_then( + s: types.AssetClass, + debt: Int, + unweighted_capacity: Int, + borrowing_capacity: Int, + qty: Int, + asset_list: List, + price_list: List, + prices_denom: Int, + weights_denom: Int, + weights_list: List, + max_proportion_list: List, + continue_with: fn( + Int, + Int, + Int, + List, + List, + Int, + Int, + List, + List, + fn(Int, Int) -> a, + ) -> + a, + callback: fn(Int, Int) -> a, +) -> a { + expect [asset_name, ..] = asset_list + if s == asset_name { + expect [price, ..] = price_list + let numerator = qty * price + continue_with( + debt, + unweighted_capacity + numerator, + borrowing_capacity + math.min( + // First case: the asset is not limited by the max proportion + numerator * weights_denom / builtin.head_list(weights_list) / prices_denom, + // Second case: the asset is limited by the max proportion + debt * builtin.head_list(max_proportion_list) / types.bp_precision, + ), + builtin.tail_list(asset_list), + builtin.tail_list(price_list), + prices_denom, + weights_denom, + builtin.tail_list(weights_list), + builtin.tail_list(max_proportion_list), + callback, + ) + } else { + get_asset_price_then( + s: s, + debt: debt, + unweighted_capacity: unweighted_capacity, + borrowing_capacity: borrowing_capacity, + qty: qty, + asset_list: builtin.tail_list(asset_list), + price_list: builtin.tail_list(price_list), + prices_denom: prices_denom, + weights_denom: weights_denom, + weights_list: builtin.tail_list(weights_list), + max_proportion_list: builtin.tail_list(max_proportion_list), + continue_with: continue_with, + callback: callback, + ) + } +} + +test basic_bc_test() { + let + a, + b, + <- + do_get_borrowing_capacity( + value.from_asset("", "", 10) + |> value.to_dict + |> dict.to_pairs, + 100, + 0, + 0, + [types.AssetClass { policy_id: "", asset_name: "" }], + [2], + 1, + 1, + [3], + [10_000], + ) + (a, b) == (20, 6) +} + +test basic_bc_test_2() { + let + a, + b, + <- + do_get_borrowing_capacity( + value.from_asset("", "", 10) + |> value.merge(value.from_asset("a", "b", 20)) + |> value.to_dict + |> dict.to_pairs, + 100, + 0, + 0, + [ + types.AssetClass { policy_id: "", asset_name: "" }, + types.AssetClass { policy_id: "a", asset_name: "b" }, + ], + [2, 2], + 1, + 1, + [3, 3], + [10_000, 10_000], + ) + (a, b) == (60, 19) +} + +test basic_bc_test_m() { + let + a, + b, + <- + do_get_borrowing_capacity( + value.from_asset("", "", 10) + |> value.merge(value.from_asset("a", "b", 20)) + |> value.merge(value.from_asset("c", "d", 20)) + |> value.merge(value.from_asset("e", "f", 20)) + |> value.merge(value.from_asset("g", "h", 20)) + |> value.merge(value.from_asset("i", "j", 20)) + |> value.to_dict + |> dict.to_pairs, + 100, + 0, + 0, + [ + types.AssetClass { policy_id: "", asset_name: "" }, + types.AssetClass { policy_id: "a", asset_name: "b" }, + types.AssetClass { policy_id: "c", asset_name: "d" }, + types.AssetClass { policy_id: "e", asset_name: "f" }, + types.AssetClass { policy_id: "g", asset_name: "h" }, + types.AssetClass { policy_id: "i", asset_name: "j" }, + ], + [2, 2, 2, 2, 2, 2], + 1, + 1, + [3, 3, 3, 3, 3, 3], + [10_000, 10_000, 10_000, 10_000, 10_000, 10_000], + ) + (a, b) == (220, 71) +} diff --git a/lib/butane/subvalidators/bad_debt.ak b/lib/butane/subvalidators/bad_debt.ak new file mode 100644 index 0000000..de3098c --- /dev/null +++ b/lib/butane/subvalidators/bad_debt.ak @@ -0,0 +1,217 @@ +use aiken/interval.{Finite, Interval, IntervalBound} +use aiken/list +use aiken/math/rational +use aiken/pairs +use aiken/transaction.{InlineDatum, Input, Output, WithdrawFrom} +use aiken/transaction/credential.{Address, Credential, Inline, ScriptCredential} +use aiken/transaction/value.{Value} +use butane/prices +use butane/types +use butane/unsafe +use butane/utils + +fn check_inputs( + valid_to: Int, + synthetic_name: ByteArray, + cdp_price_list: List, + cdp_price_denom: Int, + cdp_collateral, + cdp_weights, + cdp_denominator, + cdp_max_proportions, + cdp_interest_rates, + state_cred: Credential, + mint_script_hash: ByteArray, + inputs: List, + accv: Value, + accs: Int, + count: Int, +) { + when inputs is { + [] -> (accv, accs, count) + [inp, ..other_inputs] -> { + let Input { + output: Output { + address: Address { payment_credential: inp_cred, .. }, + value: cdp_value, + datum, + .. + }, + .. + } = inp + if inp_cred == state_cred { + expect InlineDatum(this_raw_datum) = datum + expect types.CDP { + synthetic_asset: cdp_synthetic_name, + synthetic_amount: cdp_synthetic_amount, + start_time: cdp_start_time, + .. + } = utils.to_monodatum(this_raw_datum) + let fee_percent = + utils.calculate_fee_percent( + cdp_interest_rates, + cdp_start_time, + valid_to, + ) + let fee_in_synthetic = + fee_percent * cdp_synthetic_amount / types.bp_precision / types.milliseconds_in_year + let cr, _ <- + prices.get_collateral_finances( + p_list: cdp_price_list, + p_dom: cdp_price_denom, + v: cdp_value + |> value.add(mint_script_hash, types.cdp_lock_token_name, -1), + assets: cdp_collateral, + w_list: cdp_weights, + w_dom: cdp_denominator, + max_proportions_list: cdp_max_proportions, + synth_amount: cdp_synthetic_amount + fee_in_synthetic, + callback: _, + ) + expect and { + (cdp_synthetic_name == synthetic_name)?, + (( cr |> rational.compare(rational.from_int(1)) ) == Less)?, + } + check_inputs( + valid_to, + synthetic_name, + cdp_price_list, + cdp_price_denom, + cdp_collateral, + cdp_weights, + cdp_denominator, + cdp_max_proportions, + cdp_interest_rates, + state_cred, + mint_script_hash, + other_inputs, + value.merge(accv, cdp_value), + accs + cdp_synthetic_amount, + count + 1, + ) + } else { + check_inputs( + valid_to, + synthetic_name, + cdp_price_list, + cdp_price_denom, + cdp_collateral, + cdp_weights, + cdp_denominator, + cdp_max_proportions, + cdp_interest_rates, + state_cred, + mint_script_hash, + other_inputs, + accv, + accs, + count, + ) + } + } + } +} + +pub fn bad_debt( + inputs, + outputs, + reference_inputs, + redeemers, + validity_range, + mint, + state_script_hash: ByteArray, + price_feed_script_hash: ByteArray, + mint_script_hash: types.ScriptHash, + treasury_out_idx: Int, +) { + let state_cred = ScriptCredential(state_script_hash) + expect Interval { + upper_bound: IntervalBound { bound_type: Finite(valid_to), .. }, + .. + } = validity_range + expect [ + types.ParamsData { + params: types.LiveParams{params: types.ActiveParams { + weights: cdp_weights, + collateral_assets: cdp_collateral, + denominator: cdp_denominator, + max_proportions: cdp_max_proportions, + interest_rates: cdp_interest_rates, + .. + }}, + synthetic: synthetic_name, + }, + ] = utils.params_from_refs(reference_inputs, mint_script_hash) + let price_feed_redeemer_raw = + unsafe.unsome( + pairs.get_first( + redeemers, + WithdrawFrom(utils.stake_cred_from_hash(price_feed_script_hash)), + ), + ) + let price_feed_redeemer = utils.to_pricefeedredeemer(price_feed_redeemer_raw) + expect [ + types.PriceFeed { + collateral_prices: cdp_price_list, + denominator: cdp_price_denom, + synthetic: pricefeed_synthetic, + validity: pricefeed_validity_range, + }, + ]: List = list.map(price_feed_redeemer, fn(p) { p.data }) + + // Tx validity range is a subset of the price feed's validity range + expect + utils.check_price_feed_validity(pricefeed_validity_range, validity_range) + + let (input_value, burning_synthetics, count) = + check_inputs( + valid_to, + synthetic_name, + cdp_price_list, + cdp_price_denom, + cdp_collateral, + cdp_weights, + cdp_denominator, + cdp_max_proportions, + cdp_interest_rates, + state_cred, + mint_script_hash, + inputs, + value.zero(), + 0, + 0, + ) + let expected_value = + input_value + |> value.add(mint_script_hash, types.cdp_lock_token_name, -count) + |> value.add(mint_script_hash, types.gov_lock_token_name, 1) + let minted_value = + value.from_asset(mint_script_hash, types.cdp_lock_token_name, -count) + |> value.add(mint_script_hash, types.gov_lock_token_name, 1) + |> value.tokens(mint_script_hash) + expect Output { + value: treasury_value, + datum: InlineDatum(raw_treasury_datum), + address: treasury_receipt_address, + .. + } = outputs |> unsafe.list_at(treasury_out_idx) + expect types.TreasuryDatum{treas: types.TreasuryWithDebt { + debt: types.TreasuryDebt { + amount: treasury_debt_amount, + asset: treasury_debt_asset, + }, + creation_time, + }}: types.MonoDatum = raw_treasury_datum + and { + (creation_time == Some(valid_to))?, + (treasury_receipt_address == Address( + state_cred, + Some(Inline(ScriptCredential(mint_script_hash))), + ))?, + (( mint |> value.from_minted_value |> value.tokens(mint_script_hash) ) == minted_value)?, + (treasury_value == expected_value)?, + (treasury_debt_amount == burning_synthetics)?, + (pricefeed_synthetic == synthetic_name)?, + (treasury_debt_asset == pricefeed_synthetic)?, + } +} diff --git a/lib/butane/subvalidators/cdp_script.ak b/lib/butane/subvalidators/cdp_script.ak new file mode 100644 index 0000000..3ad90ef --- /dev/null +++ b/lib/butane/subvalidators/cdp_script.ak @@ -0,0 +1,833 @@ +use aiken/builtin +use aiken/dict.{Dict} +use aiken/hash.{Blake2b_224, Hash} +use aiken/interval.{Finite, Interval, IntervalBound} +use aiken/list +use aiken/math/rational +use aiken/pairs +use aiken/transaction.{ + InlineDatum, Input, Output, Redeemer, ScriptPurpose, ValidityRange, + WithdrawFrom, +} +use aiken/transaction/credential.{ + Address, Inline, ScriptCredential, StakeCredential, VerificationKey, + VerificationKeyCredential, +} +use aiken/transaction/value.{AssetName, MintedValue, Value} +use butane/prices +use butane/types +use butane/unsafe +use butane/utils + +pub fn spend_transitions( + inputs: List, + outputs: List, + state_script_hash: types.ScriptHash, + mint_script_hash: types.ScriptHash, + leftovers_script_hash: types.ScriptHash, + valid_to: Int, + valid_from: Int, + extra_signatories: List>, + withdrawals: Pairs, + fee_token: types.AssetClass, + redemption_authorized: fn() -> Bool, + param_list: List, + price_feeds_list: List, + spends: List, + x_mint: Dict, + x_btn_delta: Int, + x_fee: Value, + x_lock_mints: Int, + callback: fn(List, Dict, Int, Value, Int) -> a, +) { + when spends is { + [] -> { + // Not spending anything else from the state script without validation + expect + { + let i <- list.all(inputs) + i.output.address.payment_credential != ScriptCredential( + state_script_hash, + ) + }? + // Validating outputs + callback(outputs, x_mint, x_btn_delta, x_fee, x_lock_mints) + } + [ + types.SpendAction { spend_type: this_action, params_idx, fee_type }, + ..remaining_actions + ] -> { + let pass_in = + fn(remaining_inputs, remaining_outputs, d_lock_mints: Int) { + fn(y_mint: Dict, y_btn_delta: Int, y_fee: Value) { + spend_transitions( + remaining_inputs, + remaining_outputs, + state_script_hash, + mint_script_hash, + leftovers_script_hash, + valid_to, + valid_from, + extra_signatories, + withdrawals, + fee_token, + redemption_authorized, + param_list, + price_feeds_list, + remaining_actions, + dict.union_with( + x_mint, + y_mint, + fn(_, q0, q1) { + let q = q0 + q1 + if q == 0 { + None + } else { + Some(q) + } + }, + ), + x_btn_delta + y_btn_delta, + value.merge(y_fee, x_fee), + x_lock_mints + d_lock_mints, + callback, + ) + } + } + expect [ + Input { + output: Output { datum: input_datum, value: cdp_value, .. }, + output_reference: this_oref, + }, + ..remaining_inputs + ] = utils.until_input_from(ScriptCredential(state_script_hash), inputs) + expect InlineDatum(data) = input_datum + expect types.CDP { + owner, + synthetic_asset: cdp_synthetic_name, + synthetic_amount: cdp_synthetic_amount, + start_time: cdp_start_time, + } = utils.to_monodatum(data) + expect types.ParamsData { + params: types.LiveParams{params: types.ActiveParams { + collateral_assets: cdp_assets, + weights: cdp_weights, + denominator: cdp_denominator, + max_liquidation_return: cdp_max_liquidation_return, + max_proportions: cdp_max_proportions, + treasury_liquidation_share: cdp_treasury_liquidation_share, + minimum_outstanding_synthetic: cdp_min_outstanding, + redemption_share: cdp_redemption_share, + interest_rates: cdp_interest_rates, + fee_token_discount, + staking_interest_rates, + }}, + synthetic: synthetic_name, + } = unsafe.list_at(param_list, params_idx) + let types.PriceFeed { + collateral_prices: p_list, + denominator: p_denom, + .. + } = unsafe.list_at(price_feeds_list, params_idx) + + expect (cdp_synthetic_name == synthetic_name)? + + let cdp_value_no_lock = + cdp_value |> value.add(mint_script_hash, types.cdp_lock_token_name, -1) + let fee_percent = + utils.calculate_fee_percent( + cdp_interest_rates, + cdp_start_time, + valid_to, + ) + + let fee_in_synthetic = + fee_percent * cdp_synthetic_amount / types.bp_precision / types.milliseconds_in_year + + when this_action is { + types.RepayCDP(verifier) -> { + // Authorized by owner + expect + utils.authorization_check( + fcredential: owner, + this_oref: this_oref, + verifier: verifier, + extra_signatories: extra_signatories, + inputs: inputs, + withdrawals: withdrawals, + valid_from: valid_from, + valid_to: valid_to, + )? + utils.get_state_delta( + synthetic_name: synthetic_name, + repaid_amount: cdp_synthetic_amount, + fee_type: fee_type, + fee_in_synthetic: fee_in_synthetic, + staking_interest_rates: staking_interest_rates, + cdp_start_time: cdp_start_time, + cdp_close_time: valid_to, + fee_token_discount: fee_token_discount, + treasury_share: value.zero(), + p_list: p_list, + p_denom: p_denom, + collateral_assets: cdp_assets, + fee_token: fee_token, + g: pass_in(remaining_inputs, outputs, -1), + ) + } + types.LiquidateCDP -> { + // Full liquidation, no leftovers + let max_liquidation_return_rat = + unsafe.unsome( + rational.new(cdp_max_liquidation_return, types.bp_precision), + ) + let cr, hf <- + prices.get_collateral_finances( + p_list: p_list, + p_dom: p_denom, + v: cdp_value_no_lock, + assets: cdp_assets, + w_list: cdp_weights, + w_dom: cdp_denominator, + max_proportions_list: cdp_max_proportions, + synth_amount: cdp_synthetic_amount + fee_in_synthetic, + callback: _, + ) + expect and { + // Must be unhealthy + (rational.compare(hf, rational.from_int(1)) == Less)?, + // Must not be earning more than max liquidation return + (rational.compare(cr, max_liquidation_return_rat) != Greater)?, + } + let treasury_share = + utils.get_treasury_share( + cdp_value, + cdp_treasury_liquidation_share, + cr, + ) + + utils.get_state_delta( + synthetic_name: synthetic_name, + repaid_amount: cdp_synthetic_amount, + fee_type: fee_type, + fee_in_synthetic: fee_in_synthetic, + staking_interest_rates: staking_interest_rates, + cdp_start_time: cdp_start_time, + cdp_close_time: valid_to, + fee_token_discount: fee_token_discount, + treasury_share: treasury_share, + p_list: p_list, + p_denom: p_denom, + collateral_assets: cdp_assets, + fee_token: fee_token, + g: pass_in(remaining_inputs, outputs, -1), + ) + } + types.PartialLiquidateCDP { repay_amount } -> { + let max_liquidation_return_rat = + unsafe.unsome( + rational.new(cdp_max_liquidation_return, types.bp_precision), + ) + let _cr, hf <- + prices.get_collateral_finances( + p_list: p_list, + p_dom: p_denom, + v: cdp_value_no_lock, + assets: cdp_assets, + w_list: cdp_weights, + w_dom: cdp_denominator, + max_proportions_list: cdp_max_proportions, + synth_amount: cdp_synthetic_amount + fee_in_synthetic, + callback: _, + ) + // If there's leftover collateral for the borrower to reclaim, there should be a new output + expect Output { + address: Address { + payment_credential: ScriptCredential(leftover_output_script_hash), + .. + }, + datum: InlineDatum(leftover_datum_data), + value: leftover_value, + .. + } = builtin.head_list(outputs) + + let claimed_value_no_lock = + value.merge(cdp_value, value.negate(leftover_value)) + + let claimed_value_cr, _ <- + prices.get_collateral_finances( + p_list: p_list, + p_dom: p_denom, + v: claimed_value_no_lock, + assets: cdp_assets, + w_list: cdp_weights, + w_dom: cdp_denominator, + max_proportions_list: cdp_max_proportions, + synth_amount: repay_amount, + callback: _, + ) + + // Claiming at most the max_liquidation_return + let claimed_value_comp = + rational.compare(claimed_value_cr, max_liquidation_return_rat) + expect claimed_value_comp != Greater + + expect leftover_output_script_hash == state_script_hash + // If creating a new CDP from the leftovers (partial liquidation) + expect types.CDP { + synthetic_amount: leftover_synthetic_amount, + start_time: leftover_start_time, + owner: leftover_owner, + synthetic_asset: leftover_synthetic_name, + } = utils.to_monodatum(leftover_datum_data) + + // New CDP still has lock token + expect + value.quantity_of( + leftover_value, + mint_script_hash, + types.cdp_lock_token_name, + ) == 1 + + let leftover_fee_in_synthetic = + fee_percent * leftover_synthetic_amount / types.bp_precision / types.milliseconds_in_year + let _, new_hf <- + prices.get_collateral_finances( + p_list: p_list, + p_dom: p_denom, + v: leftover_value + |> value.add(mint_script_hash, types.cdp_lock_token_name, -1), + assets: cdp_assets, + w_list: cdp_weights, + w_dom: cdp_denominator, + max_proportions_list: cdp_max_proportions, + synth_amount: leftover_synthetic_amount + leftover_fee_in_synthetic, + callback: _, + ) + let actual_repayed_amount = + cdp_synthetic_amount - leftover_synthetic_amount + let proportional_fee_in_synthetic = + fee_percent * actual_repayed_amount / types.bp_precision / types.milliseconds_in_year + expect and { + // CDP must be unhealthy + (rational.compare(hf, rational.from_int(1)) == Less)?, + // Liquidation must be partial + repay_amount < cdp_synthetic_amount, + leftover_synthetic_amount >= cdp_min_outstanding, + // Haven't changed any constants in the datum + leftover_synthetic_name == synthetic_name, + leftover_owner == owner, + leftover_start_time == cdp_start_time, + // Repayed the right amount + actual_repayed_amount == repay_amount, + // New CDP is healthy + (rational.compare(new_hf, rational.from_int(1)) != Less)?, + } + + let treasury_share = + utils.get_treasury_share( + claimed_value_no_lock, + cdp_treasury_liquidation_share, + claimed_value_cr, + ) + + utils.get_state_delta( + synthetic_name: synthetic_name, + repaid_amount: repay_amount, + fee_type: fee_type, + fee_in_synthetic: proportional_fee_in_synthetic, + staking_interest_rates: staking_interest_rates, + cdp_start_time: cdp_start_time, + cdp_close_time: valid_to, + fee_token_discount: fee_token_discount, + treasury_share: treasury_share, + p_list: p_list, + p_denom: p_denom, + collateral_assets: cdp_assets, + fee_token: fee_token, + g: pass_in(remaining_inputs, builtin.tail_list(outputs), 0), + ) + } + types.LeftoversLiquidateCDP -> { + let max_liquidation_return_rat = + unsafe.unsome( + rational.new(cdp_max_liquidation_return, types.bp_precision), + ) + let cr, hf <- + prices.get_collateral_finances( + p_list: p_list, + p_dom: p_denom, + v: cdp_value_no_lock, + assets: cdp_assets, + w_list: cdp_weights, + w_dom: cdp_denominator, + max_proportions_list: cdp_max_proportions, + synth_amount: cdp_synthetic_amount + fee_in_synthetic, + callback: _, + ) + // If there's leftover collateral for the borrower to reclaim, there should be a new output + expect Output { + address: Address { + payment_credential: ScriptCredential(leftover_output_script_hash), + .. + }, + datum: InlineDatum(leftover_datum_data), + value: leftover_value, + .. + } = builtin.head_list(outputs) + + let claimed_value = + value.merge(cdp_value, value.negate(leftover_value)) + // Account for case where leftover burns lock token + let claimed_value_no_lock = + claimed_value + |> value.add(mint_script_hash, types.cdp_lock_token_name, -1) + + let claimed_value_cr, _ <- + prices.get_collateral_finances( + p_list: p_list, + p_dom: p_denom, + v: claimed_value_no_lock, + assets: cdp_assets, + w_list: cdp_weights, + w_dom: cdp_denominator, + max_proportions_list: cdp_max_proportions, + synth_amount: cdp_synthetic_amount, + callback: _, + ) + + let treasury_share = + utils.get_treasury_share( + claimed_value_no_lock, + cdp_treasury_liquidation_share, + claimed_value_cr, + ) + // If fully liquidated but there's still leftovers + expect types.LeftoversDatum { owner: leftover_owner }: types.LeftoversDatum = + leftover_datum_data + expect and { + // Collections script datum must be owned by CDP owner + (leftover_owner == owner)?, + (leftover_output_script_hash == leftovers_script_hash)?, + // Claiming at most the max_liquidation_return + (rational.compare(claimed_value_cr, max_liquidation_return_rat) != Greater)?, + // Must be unhealthy + (rational.compare(hf, rational.from_int(1)) == Less)?, + // We must be exceeding the maximum allowed earnings, forcing a leftover + (rational.compare(cr, max_liquidation_return_rat) == Greater)?, + } + + utils.get_state_delta( + synthetic_name: synthetic_name, + repaid_amount: cdp_synthetic_amount, + fee_type: fee_type, + fee_in_synthetic: fee_in_synthetic, + staking_interest_rates: staking_interest_rates, + cdp_start_time: cdp_start_time, + cdp_close_time: valid_to, + fee_token_discount: fee_token_discount, + treasury_share: treasury_share, + p_list: p_list, + p_denom: p_denom, + collateral_assets: cdp_assets, + fee_token: fee_token, + g: pass_in(remaining_inputs, builtin.tail_list(outputs), -1), + ) + } + types.RedeemCDP -> { + expect redemption_authorized() + let repaying = cdp_synthetic_amount + fee_in_synthetic + let redeeming_value = + repaying * cdp_redemption_share / types.bp_precision + let collateral_value, _ <- + prices.get_collateral_finances( + p_list: p_list, + p_dom: p_denom, + v: cdp_value_no_lock, + assets: cdp_assets, + w_list: cdp_weights, + w_dom: cdp_denominator, + max_proportions_list: cdp_max_proportions, + synth_amount: 1, + callback: _, + ) + let collateral_value = + collateral_value + |> rational.truncate + expect [ + Output { + value: leftover_value, + address: Address { + payment_credential: ScriptCredential( + leftover_output_script_hash, + ), + .. + }, + datum: InlineDatum(leftover_datum_data), + .. + }, + ..remaining_outputs + ] = outputs + expect types.LeftoversDatum { owner: leftover_owner }: types.LeftoversDatum = + leftover_datum_data + expect leftover_owner == owner + let leftover_collateral_value, _ <- + prices.get_collateral_finances( + p_list: p_list, + p_dom: p_denom, + v: leftover_value, + assets: cdp_assets, + w_list: cdp_weights, + w_dom: cdp_denominator, + max_proportions_list: cdp_max_proportions, + synth_amount: 1, + callback: _, + ) + let leftover_collateral_value = + leftover_collateral_value |> rational.truncate + expect fee_type == types.FeeInSynthetic + expect leftover_collateral_value >= collateral_value - redeeming_value + // leftover_value's entries must be smaller than or equal to cdp_value_no_lock's corresponding entries (can't add any additional tokens) + expect { + let pol, tok, qty, acc <- value.reduce(leftover_value, True) + acc && value.quantity_of(cdp_value_no_lock, pol, tok) >= qty + } + expect leftover_output_script_hash == leftovers_script_hash + utils.get_state_delta( + synthetic_name: synthetic_name, + repaid_amount: cdp_synthetic_amount, + fee_type: types.FeeInSynthetic, + fee_in_synthetic: fee_in_synthetic, + staking_interest_rates: [], + cdp_start_time: cdp_start_time, + cdp_close_time: 0, + fee_token_discount: 0, + treasury_share: value.zero(), + p_list: p_list, + p_denom: p_denom, + collateral_assets: cdp_assets, + fee_token: fee_token, + g: pass_in(remaining_inputs, remaining_outputs, -1), + ) + } + } + } + } +} + +pub fn create_transitions( + mint_script_hash: types.ScriptHash, + state_script_hash: types.ScriptHash, + valid_from: Int, + valid_to: Int, + param_list: List, + price_feeds_list: List, + actions: List, + outputs: List, + x_mint: Dict, + x_btn_delta: Int, + x_fee: Value, + x_lock_mints: Int, +) { + when actions is { + [] | [0] -> + ( + types.StateDelta { + mint: x_mint, + btn_delta: x_btn_delta, + fee: x_fee, + lock_mints: x_lock_mints, + }, + builtin.head_list(outputs), + ) + [num_create, ..xsactions] -> { + expect [ + types.ParamsData { + params: types.LiveParams{params: types.ActiveParams { + weights: cdp_weights, + collateral_assets: cdp_collateral, + denominator: cdp_denominator, + minimum_outstanding_synthetic: cdp_min_outstanding, + max_proportions: cdp_max_proportions, + .. + }}, + synthetic: expected_synthetic_name, + }, + ..xs_params + ] = param_list + expect [ + types.PriceFeed { + collateral_prices: cdp_price_list, + denominator: cdp_price_denom, + .. + }, + ..xs_prices + ] = price_feeds_list + let (synthetics_minted, other_outputs) = + utils.until_zero( + num_create, + fn(a: Int, b: List) { (a, b) }, + fn(acc: fn(Int, List) -> (Int, List)) { + fn(curr_minted: Int, curr_outputs: List) -> ( + Int, + List, + ) { + when curr_outputs is { + [new_output, ..other_outputs] -> { + expect Output { + datum: InlineDatum(out_data), + address: out_address, + value: new_cdp_value, + .. + } = new_output + + expect types.CDP { + synthetic_asset: synthetic_name, + synthetic_amount: new_synthetic_amount, + start_time, + .. + } = utils.to_monodatum(out_data) + + let _, hf <- + prices.get_collateral_finances( + p_list: cdp_price_list, + p_dom: cdp_price_denom, + v: new_cdp_value + |> value.add( + mint_script_hash, + types.cdp_lock_token_name, + -1, + ), + assets: cdp_collateral, + w_list: cdp_weights, + w_dom: cdp_denominator, + max_proportions_list: cdp_max_proportions, + synth_amount: new_synthetic_amount, + callback: _, + ) + expect and { + // CDP is healthy + (rational.compare(hf, rational.from_int(1)) != Less)?, + // At script's address + (out_address.payment_credential == ScriptCredential( + state_script_hash, + ))?, + // CDP start time is the lower bound of the validity range + (start_time > 0 && start_time == valid_from)?, + // CDP has lock token + (value.quantity_of( + new_cdp_value, + mint_script_hash, + types.cdp_lock_token_name, + ) == 1)?, + // CDP is using the right params + (expected_synthetic_name == synthetic_name)?, + // CDP has enough outstanding synthetic + (new_synthetic_amount >= cdp_min_outstanding)?, + } + acc(curr_minted + new_synthetic_amount, other_outputs) + } + _ -> fail + } + } + }, + )( + 0, + outputs, + ) + create_transitions( + mint_script_hash, + state_script_hash, + valid_from, + valid_to, + xs_params, + xs_prices, + xsactions, + other_outputs, + x_mint + |> dict.insert_with( + expected_synthetic_name, + synthetics_minted, + fn(_k, a, b) { + let c = a + b + if c == 0 { + None + } else { + Some(c) + } + }, + ), + x_btn_delta, + x_fee, + x_lock_mints + num_create, + ) + } + } +} + +pub fn cdp_script( + mint: MintedValue, + outputs: List, + redeemers: Pairs, + inputs: List, + reference_inputs: List, + extra_signatories: List>, + validity_range: ValidityRange, + withdrawals: Pairs, + mint_script_hash: types.ScriptHash, + state_script_hash: types.ScriptHash, + spends: List, + extra: List, + price_feed_script_hash: types.ScriptHash, + leftovers_script_hash: types.ScriptHash, + fee_token: types.AssetClass, + redemption_nft: types.AssetClass, +) -> Bool { + let redemption_authorized = + fn() { + let types.AssetClass { + policy_id: redemption_pid, + asset_name: redemption_an, + } = redemption_nft + let Input { + output: Output { + address: Address { payment_credential: redemption_nft_credential, .. }, + .. + }, + .. + } = + unsafe.unsome( + list.find( + reference_inputs, + fn(r: Input) { + value.quantity_of(r.output.value, redemption_pid, redemption_an) == 1 + }, + ), + ) + when redemption_nft_credential is { + VerificationKeyCredential(key_hash) -> + list.has(extra_signatories, key_hash) + script_cred -> utils.withdraws_zero(withdrawals, Inline(script_cred)) + } + } + expect Interval { + lower_bound: IntervalBound { bound_type: Finite(valid_from), .. }, + upper_bound: IntervalBound { bound_type: Finite(valid_to), .. }, + } = validity_range + let param_list = utils.params_from_refs(reference_inputs, mint_script_hash) + let price_feed_redeemer_raw = + unsafe.unsome( + pairs.get_first( + redeemers, + WithdrawFrom(utils.stake_cred_from_hash(price_feed_script_hash)), + ), + ) + let price_feed_redeemer = utils.to_pricefeedredeemer(price_feed_redeemer_raw) + let price_feeds_list: List = { + let + types.Feed { data: p_data, .. }, + types.ParamsData { synthetic: params_synth, .. }, + <- list.map2(price_feed_redeemer, param_list) + expect and { + // All price feeds are valid for this transaction + utils.check_price_feed_validity(p_data.validity, validity_range)?, + // All price feeds are for the correct synthetic + (p_data.synthetic == params_synth)?, + } + p_data + } + let remaining_outputs, x_mint, x_btn_delta, x_fee, x_lock_mints <- + spend_transitions( + inputs: inputs, + outputs: outputs, + state_script_hash: state_script_hash, + mint_script_hash: mint_script_hash, + leftovers_script_hash: leftovers_script_hash, + valid_to: valid_to, + valid_from: valid_from, + extra_signatories: extra_signatories, + withdrawals: withdrawals, + fee_token: fee_token, + redemption_authorized: redemption_authorized, + param_list: param_list, + price_feeds_list: price_feeds_list, + spends: spends, + x_mint: dict.new(), + x_btn_delta: 0, + x_fee: value.zero(), + x_lock_mints: 0, + callback: _, + ) + let ( + types.StateDelta { + mint: state_mint, + btn_delta: state_btn_delta, + fee: state_fee, + lock_mints: state_lock_mints, + }, + maybe_fee_output, + ) = + create_transitions( + mint_script_hash: mint_script_hash, + state_script_hash: state_script_hash, + valid_from: valid_from, + valid_to: valid_to, + param_list: param_list, + price_feeds_list: price_feeds_list, + actions: extra, + outputs: remaining_outputs, + x_mint: x_mint, + x_btn_delta: x_btn_delta, + x_fee: x_fee, + x_lock_mints: x_lock_mints, + ) + let valid_fee_output = or { + state_fee == value.zero(), + { + let expected_fee_address = + Address { + payment_credential: ScriptCredential(state_script_hash), + stake_credential: Some(Inline(ScriptCredential(mint_script_hash))), + } + let Output { + value: actual_fee_utxo_value, + address: actual_fee_utxo_address, + datum: actual_fee_utxo_datum, + .. + } = maybe_fee_output + and { + // Sending at least the correct fee + { + let pol, tok, qty, acc <- value.reduce(actual_fee_utxo_value, True) + (acc && value.quantity_of(state_fee, pol, tok) <= qty)? + }, + // At the correct address + (actual_fee_utxo_address == expected_fee_address)?, + // With the correct datum + (actual_fee_utxo_datum == InlineDatum( + types.TreasuryDatum { treas: types.TreasuryFromFees }, + ))?, + } + }, + } + let types.AssetClass { policy_id: fee_token_pid, asset_name: fee_token_name } = + fee_token + let minted_value = value.from_minted_value(mint) + let state_mint_after_insertion = + if state_lock_mints == 0 { + state_mint + } else { + state_mint + |> dict.insert(types.cdp_lock_token_name, state_lock_mints) + } + and { + // Mint here is correct + (state_mint_after_insertion == ( + minted_value |> value.tokens(mint_script_hash) + ))?, + // Mint btn is correct + (( minted_value |> value.quantity_of(fee_token_pid, fee_token_name) ) == state_btn_delta)?, + // Fees are correct + valid_fee_output?, + } +} diff --git a/lib/butane/subvalidators/gov_issue.ak b/lib/butane/subvalidators/gov_issue.ak new file mode 100644 index 0000000..c8e7ebf --- /dev/null +++ b/lib/butane/subvalidators/gov_issue.ak @@ -0,0 +1,99 @@ +use aiken/list +use aiken/pairs +use aiken/transaction.{InlineDatum, Input, Output} +use aiken/transaction/credential.{ScriptCredential} +use aiken/transaction/value.{MintedValue} +use butane/types.{AssetClass} +use butane/unsafe +use butane/utils + +pub fn gov_issue( + inputs: List, + outputs: List, + mint: MintedValue, + gov_nft: AssetClass, + state_hash: ByteArray, + mint_script_hash: types.ScriptHash, + output_idx: Int, +) { + let minted = value.from_minted_value(mint) + let gov_output = unsafe.list_at(outputs, output_idx) + // 0 inputs from the state script + expect [] = + inputs + |> list.filter( + fn(inp) { + inp.output.address.payment_credential == ScriptCredential( + state_hash, + ) + }, + ) + expect InlineDatum(raw_datum) = gov_output.datum + expect types.GovDatum { .. } = utils.to_monodatum(raw_datum) + + and { + // Gov NFT is found (authorization) + list.any( + inputs, + fn(input) { + value.quantity_of( + input.output.value, + gov_nft.policy_id, + gov_nft.asset_name, + ) > 0 + }, + )?, + // Correctly mint 1 singular control token + utils.only_mints_this( + minted, + mint_script_hash, + types.gov_lock_token_name, + 1, + )?, + // Output has the correct value + (value.without_lovelace(gov_output.value) == value.from_asset( + mint_script_hash, + types.gov_lock_token_name, + 1, + ))?, + // ...and is at the correct address + (gov_output.address.payment_credential == ScriptCredential(state_hash))?, + } +} + +pub fn consume_external( + inputs: List, + mint, + withdrawals, + state_hash: ByteArray, + mint_script_hash: types.ScriptHash, +) { + let minted = value.from_minted_value(mint) + expect [gov_input] = + inputs + |> list.filter( + fn(inp) { + inp.output.address.payment_credential == ScriptCredential( + state_hash, + ) + }, + ) + let gov_output = gov_input.output + expect InlineDatum(raw_datum) = gov_output.datum + expect types.GovDatum{gov: types.ExternalScript { other_script, .. }} = + utils.to_monodatum(raw_datum) + and { + utils.only_mints_this( + minted, + mint_script_hash, + types.gov_lock_token_name, + -1, + )?, + (value.without_lovelace(gov_output.value) == value.from_asset( + mint_script_hash, + types.gov_lock_token_name, + 1, + ))?, + pairs.has_key(withdrawals, utils.stake_cred_from_hash(other_script)), + } +} diff --git a/lib/butane/subvalidators/next_compat.ak b/lib/butane/subvalidators/next_compat.ak new file mode 100644 index 0000000..b829a64 --- /dev/null +++ b/lib/butane/subvalidators/next_compat.ak @@ -0,0 +1,126 @@ +use aiken/dict +use aiken/list +use aiken/transaction.{InlineDatum, Input, Output} +use aiken/transaction/credential.{ScriptCredential} +use aiken/transaction/value +use butane/types +use butane/unsafe +use butane/utils + +pub fn compat_locking( + inputs, + reference_inputs, + outputs, + mint, + state_script_hash: types.ScriptHash, + mint_script_hash: types.ScriptHash, + redeemer: types.CompatRedeemer, +) -> Bool { + expect Input { + output: Output { datum: InlineDatum(raw_gov_datum), value: gov_value, .. }, + .. + } = + unsafe.unsome( + utils.find_input_with_credential( + reference_inputs, + ScriptCredential(state_script_hash), + ), + ) + expect types.GovDatum{gov: types.GovNewCompat { upgrade_policy }} = + utils.to_monodatum(raw_gov_datum) + + when redeemer is { + types.CompatLock { oidx } -> { + let Output { value, datum, address, .. } = unsafe.list_at(outputs, oidx) + expect [Pair(mint_token_name, mint_qty)] = + mint + |> value.from_minted_value + |> value.tokens(mint_script_hash) + |> dict.to_pairs + and { + // Datum is correct + (datum == InlineDatum(types.CompatLockedTokens))?, + // Address is correct + (address.payment_credential == ScriptCredential(state_script_hash))?, + // Value is correct + ( value |> value.without_lovelace() ) == value.from_asset( + upgrade_policy, + mint_token_name, + mint_qty, + ), + // Not spending anything at the state script address + (utils.find_input_with_credential( + inputs, + ScriptCredential(state_script_hash), + ) == None)?, + // Gov input has lock token + (value.quantity_of( + gov_value, + mint_script_hash, + types.gov_lock_token_name, + ) > 0)?, + } + } + types.CompatUnlock { soidx } -> { + expect [_, Pair(releasing_policy, releasing_tupl)] = + list.foldr( + inputs, + value.zero(), + fn(i: Input, v) { + if + i.output.address.payment_credential == ScriptCredential( + state_script_hash, + ){ + + expect InlineDatum(raw_datum) = i.output.datum + expect utils.to_monodatum(raw_datum) == types.CompatLockedTokens + value.merge(v, i.output.value) + } else { + v + } + }, + ) + |> value.to_dict + |> dict.to_pairs + + expect [Pair(releasing_token_name, releasing_amount)] = + releasing_tupl |> dict.to_pairs + expect [Pair(mint_token_name, mint_qty)] = + mint + |> value.from_minted_value + |> value.tokens(mint_script_hash) + |> dict.to_pairs + and { + (releasing_policy == upgrade_policy)?, + (mint_token_name == releasing_token_name)?, + // Gov input has lock token + (value.quantity_of( + gov_value, + mint_script_hash, + types.gov_lock_token_name, + ) > 0)?, + // Handle change + when soidx is { + Some(oidx) -> { + expect Output { + value, + address, + datum: InlineDatum(change_datum), + .. + } = unsafe.list_at(outputs, oidx) + expect types.CompatLockedTokens = utils.to_monodatum(change_datum) + and { + ( value |> value.without_lovelace() ) == value.from_asset( + upgrade_policy, + releasing_token_name, + releasing_amount + mint_qty, + ), + (address.payment_credential == ScriptCredential(state_script_hash))?, + } + } + None -> (releasing_amount == -mint_qty)? + }, + } + } + } +} diff --git a/lib/butane/subvalidators/params_script.ak b/lib/butane/subvalidators/params_script.ak new file mode 100644 index 0000000..c0e4526 --- /dev/null +++ b/lib/butane/subvalidators/params_script.ak @@ -0,0 +1,572 @@ +use aiken/builtin +use aiken/bytearray +use aiken/dict +use aiken/interval.{Finite, Interval, IntervalBound} +use aiken/list +use aiken/math +use aiken/option +use aiken/pairs +use aiken/transaction.{ + InlineDatum, Input, Output, Redeemer, ScriptPurpose, ValidityRange, + WithdrawFrom, +} +use aiken/transaction/credential.{Address, ScriptCredential} +use aiken/transaction/value.{MintedValue} +use butane/types +use butane/unsafe +use butane/utils + +pub fn params_init( + mint, + outputs, + inputs: List, + validity_range, + mint_script_hash: types.ScriptHash, + state_script_hash: types.ScriptHash, + oidx: Int, + fee_token: types.AssetClass, +) { + expect [gov_inp] = + inputs + |> list.filter( + fn(inp) { + inp.output.address.payment_credential == ScriptCredential( + state_script_hash, + ) + }, + ) + expect InlineDatum(raw_gov_datum) = gov_inp.output.datum + // expect gov_datum: ParamsGovDatum = raw_gov_datum + expect types.GovDatum(types.NewParamsAuth { params, asset: expected_asset }) = + utils.to_monodatum(raw_gov_datum) + let expected_params_d: Data = params + let (param_token_name, param_minted_qty, gov_minted_qty) = { + expect [Pair(first_token, first_minted), Pair(second_token, second_minted)] = + mint + |> value.from_minted_value + |> value.tokens(mint_script_hash) + |> dict.to_pairs + if first_token == types.gov_lock_token_name { + (second_token, second_minted, first_minted) + } else if second_token == types.gov_lock_token_name { + (first_token, first_minted, second_minted) + } else { + fail @"Invalid lock token" + } + } + expect Output { + address: Address { payment_credential, .. }, + value: params_value, + datum: InlineDatum(raw_actual_params), + .. + } = outputs |> unsafe.list_at(oidx) + expect types.ParamsWrapper(actual_params) = + utils.to_monodatum(raw_actual_params) + expect types.LiveParams{params: types.ActiveParams { + collateral_assets: p_collateral_assets, + weights: p_weights, + denominator: p_denominator, + minimum_outstanding_synthetic: p_min_outstanding, + interest_rates: p_interest_rates, + fee_token_discount: p_fee_token_discount, + max_liquidation_return: p_max_return, + treasury_liquidation_share: p_treasury_share, + redemption_share: p_redemption_share, + max_proportions: p_max_proportions, + staking_interest_rates: p_staking_interest_rates, + }} = actual_params + let data_actual_params: Data = actual_params + let valid_output_value = + ( params_value |> value.without_lovelace() ) == value.from_asset( + mint_script_hash, + param_token_name, + 1, + ) + + expect [ + (p_first_interest_time, p_first_rate), + (p_max_interest_time, p_max_rate), + ] = p_interest_rates + + expect [(p_first_staking_time, p_first_staking_rate)] = + p_staking_interest_rates + + expect Interval { + lower_bound: IntervalBound { bound_type: Finite(valid_from), .. }, + .. + } = validity_range + + and { + // Params token name is in correct format (contains the "p_" prefix followed by the correct synthetic name) + (param_token_name == bytearray.concat(types.params_prefix, expected_asset))?, + // Synthetic name isn't a reserved name or invalid + (expected_asset != types.cdp_lock_token_name)?, + (expected_asset != types.gov_lock_token_name)?, + (bytearray.take(expected_asset, types.params_prefix_length) != types.params_prefix)?, + (bytearray.take(expected_asset, types.debt_prefix_length) != types.debt_prefix)?, + // Minting one params token + (param_minted_qty == 1)?, + // Burning one gov lock token + (gov_minted_qty == -1)?, + // Sending to this script + (payment_credential == ScriptCredential(state_script_hash))?, + valid_output_value?, + // Gov input contains lock token + utils.gov_has_lock_token(gov_inp, mint_script_hash)?, + (expected_params_d == data_actual_params)?, + // Collateral assets are sorted by asset class and no duplicates (same as in values) + (list.sort(p_collateral_assets, utils.compare_asset_classes) == p_collateral_assets)?, + utils.sorted_list_is_unique(p_collateral_assets)?, + // The synthetic is not in the list of collateral assets + !list.has( + p_collateral_assets, + types.AssetClass { + policy_id: mint_script_hash, + asset_name: expected_asset, + }, + )?, + // Collateral assets contain ADA and BTN (the weights can be + // set to be very high to indicate that they arent't to be used as collateral, + // they just need to be in the list for the price feeds) + (list.at(p_collateral_assets, 0) == Some( + types.AssetClass { + policy_id: value.ada_policy_id, + asset_name: value.ada_asset_name, + }, + ))?, + list.has(p_collateral_assets, fee_token)?, + // All weights are > 1 + (p_denominator > 0)?, + list.all(p_weights, fn(a) { a > p_denominator })?, + // Length checks + (list.length(p_weights) == list.length(p_collateral_assets))?, + (list.length(p_max_proportions) == list.length(p_collateral_assets))?, + // Max proportions are all positive + list.all(p_max_proportions, fn(p) { p > 0 })?, + // The denominator is the minimal possible denominator + utils.is_minimal_denom(p_weights, p_denominator)?, + (p_min_outstanding > 0)?, + (p_first_rate > 0)?, + (p_first_staking_rate > 0)?, + (p_first_staking_rate <= p_first_rate)?, + // Interest rate start time is the lower bound of the tx validity range (negative to ensure that the most recent rate is the first one in the list, since lists of pairs are maps sorted by keys) + (p_first_interest_time == -valid_from)?, + (p_first_staking_time == -valid_from)?, + (p_max_interest_time == 0)?, + (p_max_rate == p_first_rate)?, + // Fee token discount is between 0 and 10_000 + (p_fee_token_discount >= 0 && p_fee_token_discount <= types.bp_precision)?, + // Max return is greater than 1 (in basis points) + p_max_return > types.bp_precision, + // Treasury liquidation share is between 0 and 1 (in basis points) + p_treasury_share > 0, + p_treasury_share < types.bp_precision, + // Redemption share is between 0 and 1 (in basis points) + p_redemption_share > 0, + p_redemption_share < types.bp_precision, + } +} + +pub fn params_update( + mint, + outputs, + validity_range: ValidityRange, + inputs: List, + mint_script_hash: types.ScriptHash, + state_script_hash: types.ScriptHash, + oidx: Int, + reverse_order: Bool, +) -> Bool { + let ( + gov_inp, + Input { + output: Output { datum: param_inp_datum, value: param_inp_value, .. }, + .. + }, + ) = { + let filtered_inputs = + inputs + |> list.filter( + fn(inp) { + inp.output.address.payment_credential == ScriptCredential( + state_script_hash, + ) + }, + ) + expect [a, b] = filtered_inputs + if reverse_order { + (a, b) + } else { + (b, a) + } + } + let minted = value.from_minted_value(mint) + + expect InlineDatum(raw_gov_datum) = gov_inp.output.datum + expect types.GovDatum(types.UpdateParamsAuth { + asset: synthetic_asset, + action, + }) = utils.to_monodatum(raw_gov_datum) + + let expected_lock_token = + bytearray.concat(types.params_prefix, synthetic_asset) + + expect InlineDatum(raw_params_datum) = param_inp_datum + + expect types.ParamsWrapper(types.LiveParams { params: old_params }) = + utils.to_monodatum(raw_params_datum) + let types.ActiveParams { + collateral_assets: old_collateral_assets, + weights: old_weights, + denominator: old_denominator, + interest_rates: old_rates, + max_proportions: old_max_proportions, + staking_interest_rates: old_staking_rates, + .. + } = old_params + let expected_params: types.Params = + when action is { + types.NewCollateral { + index, + collateral_asset, + weight_numerator, + weight_denominator, + max_proportion, + } -> { + expect and { + // Adding a new collateral asset + list.has(old_collateral_assets, collateral_asset) == False, + // Not using the synth as collateral + types.AssetClass { + policy_id: mint_script_hash, + asset_name: synthetic_asset, + } != collateral_asset, + // Weight > 1 + weight_numerator > weight_denominator, + // Max proportion > 0 + max_proportion > 0, + } + let (new_weights, new_denominator) = + if weight_denominator == old_denominator { + ( + utils.list_insert_at(old_weights, index, weight_numerator), + old_denominator, + ) + } else { + let lcm = utils.lcm(weight_denominator, old_denominator) + ( + utils.list_insert_at( + list.map(old_weights, fn(w) { w * lcm / old_denominator }), + index, + weight_numerator * lcm / weight_denominator, + ), + lcm, + ) + } + let new_collateral_assets = + utils.list_insert_at(old_collateral_assets, index, collateral_asset) + // The collateral list remains sorted + expect + new_collateral_assets == list.sort( + [collateral_asset, ..old_collateral_assets], + utils.compare_asset_classes, + ) + types.LiveParams { + params: types.ActiveParams { + ..old_params, + collateral_assets: new_collateral_assets, + weights: new_weights, + denominator: new_denominator, + max_proportions: utils.list_insert_at( + old_max_proportions, + index, + max_proportion, + ), + }, + } + } + types.UpdateWeight { + collateral_asset_idx, + weight_numerator, + weight_denominator, + } -> { + expect and { + // Weight > 1 + weight_numerator > weight_denominator, + // Index is in bounds + list.length(old_weights) > collateral_asset_idx, + } + let (new_weights, new_denominator) = + if weight_denominator == old_denominator { + ( + list.indexed_map( + old_weights, + fn(i, w) { + if i == collateral_asset_idx { + weight_numerator + } else { + w + } + }, + ), + old_denominator, + ) + } else { + let lcm = utils.lcm(weight_denominator, old_denominator) + ( + list.indexed_map( + old_weights, + fn(i, w) { + if i == collateral_asset_idx { + weight_numerator * lcm / weight_denominator + } else { + w * lcm / old_denominator + } + }, + ), + lcm, + ) + } + types.LiveParams { + params: types.ActiveParams { + ..old_params, + weights: new_weights, + denominator: new_denominator, + }, + } + } + types.UpdateInterest { interest_rate } -> { + expect interest_rate > 0 + expect Interval { + lower_bound: IntervalBound { bound_type: Finite(valid_from), .. }, + upper_bound: IntervalBound { bound_type: Finite(valid_to), .. }, + } = validity_range + expect Some((last_update, _)) = list.head(old_rates) + expect Some((_, old_max)) = list.last(old_rates) + expect Some(rates_no_max) = list.init(old_rates) + + expect and { + // Interest history is in reverse-chronological order + valid_from > -last_update, + // Validity range is less than a day + valid_to - valid_from < types.milliseconds_in_day, + } + + let new_rates = + list.push( + if list.length(rates_no_max) == types.num_stored_interest_rates { + list.init(rates_no_max) |> option.or_else([]) + } else { + rates_no_max + }, + (-valid_from, interest_rate), + ) + |> list.concat([(0, math.max(old_max, interest_rate))]) + types.LiveParams { + params: types.ActiveParams { ..old_params, interest_rates: new_rates }, + } + } + + types.UpdateMinOutstanding { min_outstanding } -> { + expect min_outstanding > 0 + types.LiveParams { + params: types.ActiveParams { + ..old_params, + minimum_outstanding_synthetic: min_outstanding, + }, + } + } + + types.UpdateMaxProportions { max_proportions } -> { + expect and { + list.all(max_proportions, fn(p) { p > 0 }), + list.length(max_proportions) == list.length(old_max_proportions), + } + types.LiveParams { + params: types.ActiveParams { + ..old_params, + max_proportions: max_proportions, + }, + } + } + + types.UpdateMaxLiquidationReturn { max_return } -> { + // Max return is greater than 1 (in basis points) + expect max_return > types.bp_precision + types.LiveParams { + params: types.ActiveParams { + ..old_params, + max_liquidation_return: max_return, + }, + } + } + + types.UpdateTreasuryLiquidationShare { share } -> { + // Treasury liquidation share is between 0 and 1 (in basis points) + expect share > 0 && share < types.bp_precision + types.LiveParams { + params: types.ActiveParams { + ..old_params, + treasury_liquidation_share: share, + }, + } + } + + types.UpdateRedemptionShare { share } -> { + // Redemption share is between 0 and 1 (in basis points) + expect share > 0 && share < types.bp_precision + types.LiveParams { + params: types.ActiveParams { ..old_params, redemption_share: share }, + } + } + + types.UpdateFeeTokenDiscount { discount } -> { + expect discount >= 0 && discount <= types.bp_precision + types.LiveParams { + params: types.ActiveParams { + ..old_params, + fee_token_discount: discount, + }, + } + } + + types.UpdateStakingInterest { interest_rate } -> { + expect interest_rate > 0 + expect Interval { + lower_bound: IntervalBound { bound_type: Finite(valid_from), .. }, + upper_bound: IntervalBound { bound_type: Finite(valid_to), .. }, + } = validity_range + expect Some((last_update, _)) = list.head(old_staking_rates) + + expect and { + // Interest history is in reverse-chronological order + valid_from > -last_update, + // Validity range is less than a day + valid_to - valid_from < types.milliseconds_in_day, + } + let to_push = + if list.length(old_staking_rates) == types.num_stored_interest_rates { + list.init(old_staking_rates) |> option.or_else([]) + } else { + old_staking_rates + } + + let new_rates = list.push(to_push, (-valid_from, interest_rate)) + types.LiveParams { + params: types.ActiveParams { + ..old_params, + staking_interest_rates: new_rates, + }, + } + } + } + + expect Output { + address: Address { payment_credential, .. }, + value: params_value, + datum: InlineDatum(raw_actual_params), + .. + } = outputs |> unsafe.list_at(oidx) + expect types.ParamsWrapper(actual_params) = + utils.to_monodatum(raw_actual_params) + let valid_output_value = + value.without_lovelace(params_value) == value.without_lovelace( + param_inp_value, + ) + + and { + // Gov lock token is burned with no other mints at the synthetics script + utils.only_mints_this( + minted, + mint_script_hash, + types.gov_lock_token_name, + -1, + )?, + // Gov input contains lock token + utils.gov_has_lock_token(gov_inp, mint_script_hash)?, + // Spending valid params + (value.quantity_of(param_inp_value, mint_script_hash, expected_lock_token) == 1)?, + valid_output_value?, + (expected_params == actual_params)?, + // Sending to this script + (payment_credential == ScriptCredential(state_script_hash))?, + } +} + +pub fn params_void( + mint: MintedValue, + inputs: List, + redeemers: Pairs, + outputs: List, + mint_script_hash: types.ScriptHash, + price_feed_script_hash: ByteArray, + spend_script_hash: types.ScriptHash, + validity_range: ValidityRange, +) { + expect [Input { output: spent_out, .. }] = { + let inp <- list.filter(inputs) + inp.output.address.payment_credential == ScriptCredential(spend_script_hash) + } + + let params_synthetic = { + let Output { value: inp_value, datum: inp_datum, .. } = spent_out + expect [(policy, name, amount)] = + value.flatten(inp_value |> value.without_lovelace()) + expect InlineDatum(params_data) = inp_datum + expect types.ParamsWrapper(types.LiveParams { .. }) = + utils.to_monodatum(params_data) + expect and { + policy == mint_script_hash, + bytearray.take(name, types.params_prefix_length) == types.params_prefix, + amount == 1, + } + bytearray.drop(name, types.params_prefix_length) + } + let price_feed_redeemer_raw = + unsafe.unsome( + pairs.get_first( + redeemers, + WithdrawFrom(utils.stake_cred_from_hash(price_feed_script_hash)), + ), + ) + expect [ + types.Feed { + data: types.PriceFeed { + synthetic: pf_synthetic, + denominator, + validity: pf_validity, + .. + }, + .. + }, + ] = utils.to_pricefeedredeemer(price_feed_redeemer_raw) + + // Tx validity range is a subset of the price feed's validity range + expect utils.check_price_feed_validity(pf_validity, validity_range) + + expect Output { + address: new_params_addr, + value: new_params_value, + datum: InlineDatum(raw_new_params), + reference_script, + } = builtin.head_list(outputs) + expect types.ParamsWrapper(new_params) = utils.to_monodatum(raw_new_params) + let valid_output_value = + value.without_lovelace(new_params_value) == value.without_lovelace( + spent_out.value, + ) + + let expected_lock_token = + bytearray.concat(types.params_prefix, params_synthetic) + + and { + denominator == 0, + (new_params_addr == spent_out.address)?, + valid_output_value?, + reference_script == None, + new_params == types.VoidedParams, + (params_synthetic == pf_synthetic)?, + (value.quantity_of(spent_out.value, mint_script_hash, expected_lock_token) == 1)?, + utils.mints_nothing_here(value.from_minted_value(mint), mint_script_hash)?, + } +} diff --git a/lib/butane/subvalidators/staking.ak b/lib/butane/subvalidators/staking.ak new file mode 100644 index 0000000..94a474e --- /dev/null +++ b/lib/butane/subvalidators/staking.ak @@ -0,0 +1,131 @@ +use aiken/dict +use aiken/interval.{Finite, Interval, IntervalBound} +use aiken/list +use aiken/transaction.{InlineDatum, Input, Output} +use aiken/transaction/credential.{Address, ScriptCredential} +use aiken/transaction/value +use butane/types +use butane/unsafe +use butane/utils + +// Staked value has to be locked by a lock token so we can know the authenticity of start time +// Start time always has to be after the start time of the transaction, to prevent setting it in the past, +// which would allow extraction of unearnt interest +pub fn staking_script( + mint_script_hash, + spend_script_hash, + withdrawals, + inputs, + outputs, + reference_inputs, + mint, + validity_range, + extra_signatories, + staking_redeemer, +) -> Bool { + let minted = value.from_minted_value(mint) + let state_cred = ScriptCredential(spend_script_hash) + expect [types.ParamsData { params, synthetic: params_synth }] = + utils.params_from_refs(reference_inputs, mint_script_hash) + expect Interval { + lower_bound: IntervalBound { bound_type: Finite(valid_from), .. }, + upper_bound: IntervalBound { bound_type: Finite(valid_to), .. }, + } = validity_range + when staking_redeemer is { + types.StakeSynthetics { staked_amount } -> { + expect [] = + list.filter( + inputs, + fn(i: Input) { i.output.address.payment_credential == state_cred }, + ) + expect Output { + address: Address { payment_credential, .. }, + datum: InlineDatum(raw_staked_datum), + value: staked_value, + .. + } = unsafe.list_at(outputs, 0) + expect types.StakedSynthetics { + owner: _owner, + synthetic_asset, + start_time, + } = utils.to_monodatum(raw_staked_datum) + and { + payment_credential == state_cred, + utils.only_mints_this( + minted, + mint_script_hash, + types.staking_lock_token_name, + 1, + )?, + (start_time >= valid_to)?, + (( + staked_value + |> value.without_lovelace + ) == ( + value.from_asset(mint_script_hash, types.staking_lock_token_name, 1) + |> value.add(mint_script_hash, synthetic_asset, staked_amount) + ))?, + (params_synth == synthetic_asset)?, + } + } + types.UnstakeSynthetics { verifier } -> { + expect [ + Input { + output: Output { + datum: InlineDatum(raw_staked_datum), + value: staked_value, + .. + }, + output_reference: this_oref, + }, + ] = + list.filter( + inputs, + fn(i: Input) { i.output.address.payment_credential == state_cred }, + ) + expect types.StakedSynthetics { owner, synthetic_asset, start_time } = + utils.to_monodatum(raw_staked_datum) + let end_time = valid_from + expect + utils.authorization_check( + fcredential: owner, + this_oref: this_oref, + verifier: verifier, + extra_signatories: extra_signatories, + inputs: inputs, + withdrawals: withdrawals, + valid_from: valid_from, + valid_to: valid_to, + )? + let minted_here = value.tokens(minted, mint_script_hash) |> dict.to_pairs + let staked_amount = + value.quantity_of(staked_value, mint_script_hash, synthetic_asset) + expect types.LiveParams{params: types.ActiveParams { + staking_interest_rates, + .. + }}: types.Params = params + let earnings_percent = + utils.calculate_earnings_percent( + staking_interest_rates, + start_time, + end_time, + ) + let earnings_interest = + earnings_percent * staked_amount / types.bp_precision / types.milliseconds_in_year + and { + or { + minted_here == [ + Pair(types.staking_lock_token_name, -1), + Pair(synthetic_asset, earnings_interest), + ], + minted_here == [ + Pair(synthetic_asset, earnings_interest), + Pair(types.staking_lock_token_name, -1), + ], + }, + (params_synth == synthetic_asset)?, + (end_time > start_time)?, + } + } + } +} diff --git a/lib/butane/subvalidators/treasury.ak b/lib/butane/subvalidators/treasury.ak new file mode 100644 index 0000000..e6064ac --- /dev/null +++ b/lib/butane/subvalidators/treasury.ak @@ -0,0 +1,620 @@ +use aiken/builtin +use aiken/dict +use aiken/interval.{Finite, Interval, IntervalBound} +use aiken/list +use aiken/math/rational +use aiken/pairs +use aiken/transaction.{InlineDatum, Input, Output, WithdrawFrom} +use aiken/transaction/certificate.{CredentialDelegation} +use aiken/transaction/credential.{Address, Inline, ScriptCredential} +use aiken/transaction/value.{MintedValue} +use butane/prices +use butane/types +use butane/unsafe +use butane/utils + +// All value at the treasury may either be used for burning bad debt, or for spending via governance +// When we burn bad debt, we invoke synthetics.ak's burn endpoint, and we read params in +// When we spend via governance, we destroy a locked governance token, and validate that the datum says to spend the treasury + +// Treasury has common function: spending! +// We sum up all inputs from the treasury as the 'Spend Value' +// We specify a 'change output' +// The remainder must be authorized either by the governance or debt redemption +// Governance is not allowed to spend debt, so debt must go into change +// Redemptions may also not spend debt, so debt must be burnt + +pub fn treasury_spend( + inputs: List, + outputs: List, + mint: MintedValue, + state_hash: ByteArray, + mint_script_hash: types.ScriptHash, + expected_output_idx: Int, + gov_inp_idx: Int, + // A spend tx can only use treasury inputs from one type, either from fees or from "only spend"s + inputs_from_fees: Bool, +) -> Bool { + let minted = value.from_minted_value(mint) + let state_cred = ScriptCredential(state_hash) + let script_inputs = + list.filter( + inputs, + fn(i: Input) { i.output.address.payment_credential == state_cred }, + ) + let gov_inp = unsafe.list_at(script_inputs, gov_inp_idx) + let other_inputs = list.delete(script_inputs, gov_inp) + let input_value = { + let i, acc <- list.foldl(other_inputs, value.zero()) + expect InlineDatum(input_datum) = i.output.datum + expect types.TreasuryDatum { treas: treas_type } = + utils.to_monodatum(input_datum) + expect + if inputs_from_fees { + treas_type == types.TreasuryFromFees + } else { + treas_type == types.TreasuryOnlySpend + } + value.merge(acc, i.output.value) + } + expect InlineDatum(raw_gov_datum) = gov_inp.output.datum + expect types.GovDatum(types.TreasurySpendAuth { out: expected_out_raw }) = + utils.to_monodatum(raw_gov_datum) + let expected_out = utils.fake_to_real_out(expected_out_raw) + let expected_out_value = expected_out.value + // If only a portion of the value spendable via the gov action is spent, then the remainder must be handled by another gov action + let leftover_value = { + let + policy_id, + asset_name, + qty, + acc, + <- + value.reduce( + value.merge(expected_out_value, value.negate(input_value)), + value.zero(), + ) + if qty > 0 { + value.add(acc, policy_id, asset_name, qty) + } else { + acc + } + } + expect [spent_output, ..remaining_outputs] = + list.drop(outputs, expected_output_idx) + let expected_change = + value.merge(input_value, value.negate(spent_output.value)) + let change_datum = + types.TreasuryDatum { + treas: if inputs_from_fees { + types.TreasuryFromFees + } else { + types.TreasuryOnlySpend + }, + } + + let valid_change = + fn(change_output: Output) { and { + change_output.address == Address( + state_cred, + Some(Inline(ScriptCredential(mint_script_hash))), + ), + change_output.datum == InlineDatum(change_datum), + change_output.value == expected_change, + } } + + if leftover_value != value.zero() { + let remaining_gov_output = + if expected_change != value.zero() { + expect [change_output, gov_output, ..] = remaining_outputs + expect valid_change(change_output) + gov_output + } else { + builtin.head_list(remaining_outputs) + } + expect Output { + value: remaining_gov_value, + address: Address { payment_credential: remaining_gov_pc, .. }, + datum: InlineDatum(raw_remaining_gov_datum), + .. + } = remaining_gov_output + expect types.GovDatum(types.TreasurySpendAuth { out: next_expected_out_raw }): types.MonoDatum = + raw_remaining_gov_datum + let next_expected_out = utils.fake_to_real_out(next_expected_out_raw) + let remaining_expected_gov_out = + Output { ..expected_out, value: leftover_value } + let expected_this_action_out = + Output { + ..expected_out, + value: value.merge(input_value, value.negate(expected_change)), + } + and { + // Created gov output is a valid gov action + (value.without_lovelace(remaining_gov_value) == value.from_asset( + mint_script_hash, + types.gov_lock_token_name, + 1, + ))?, + (remaining_gov_pc == state_cred)?, + // Outputs related to the spend are as expected + spent_output == expected_this_action_out, + next_expected_out == remaining_expected_gov_out, + // Not minting or burning anything + utils.mints_nothing_here(minted, mint_script_hash), + } + } else { + expect [spent_output, ..remaining_outputs] = + list.drop(outputs, expected_output_idx) + expect + expected_change == value.zero() || valid_change( + builtin.head_list(remaining_outputs), + ) + and { + // Spent output has the governance-requested value + (spent_output == expected_out)?, + // Gov lock token is burned with no other mints at the synthetics script + utils.only_mints_this( + minted, + mint_script_hash, + types.gov_lock_token_name, + -1, + )?, + // Gov input contains lock token + utils.gov_has_lock_token(gov_inp, mint_script_hash)?, + } + } +} + +pub fn treasury_mint( + inputs, + outputs: List, + mint, + state_hash: ByteArray, + mint_script_hash: types.ScriptHash, + expected_output_idx: Int, +) { + let (synth_minted_name, synth_minted_qty, gov_minted_qty) = { + expect [Pair(first_token, first_minted), Pair(second_token, second_minted)] = + mint + |> value.from_minted_value + |> value.tokens(mint_script_hash) + |> dict.to_pairs + if first_token == types.gov_lock_token_name { + (second_token, second_minted, first_minted) + } else if second_token == types.gov_lock_token_name { + (first_token, first_minted, second_minted) + } else { + fail @"Gov lock token not in mint" + } + } + let state_cred = ScriptCredential(state_hash) + expect [gov_inp] = + list.filter( + inputs, + fn(i: Input) { i.output.address.payment_credential == state_cred }, + ) + + expect InlineDatum(raw_gov_datum) = gov_inp.output.datum + expect types.GovDatum(types.TreasuryMintAuth { + asset: expected_synthetic, + amount: expected_amount, + }) = utils.to_monodatum(raw_gov_datum) + + let synth_output = unsafe.list_at(outputs, expected_output_idx) + + and { + // Datum is correct + synth_output.datum == InlineDatum( + types.TreasuryDatum { treas: types.TreasuryFromFees }, + ), + // Change output is a valid datum at the correct address: + synth_output.address.payment_credential == state_cred, + // Minting the right amount of the right synthetic + synth_minted_name == expected_synthetic, + synth_minted_qty == expected_amount, + // All of the minted synths are sent to the expected output (with no dust) + ( synth_output.value |> value.without_lovelace() ) == value.from_asset( + mint_script_hash, + expected_synthetic, + expected_amount, + ), + // Gov lock token is burned + gov_minted_qty == -1, + // Gov input contains lock token + utils.gov_has_lock_token(gov_inp, mint_script_hash)?, + } +} + +pub fn treasury_create_debt( + inputs: List, + reference_inputs: List, + outputs: List, + mint: MintedValue, + state_hash: ByteArray, + mint_script_hash: types.ScriptHash, + expected_output_idx: Int, +) { + let minted = value.from_minted_value(mint) + let state_cred = ScriptCredential(state_hash) + expect [gov_inp] = + list.filter( + inputs, + fn(i: Input) { i.output.address.payment_credential == state_cred }, + ) + + expect InlineDatum(raw_gov_datum) = gov_inp.output.datum + expect types.GovDatum(types.TreasuryCreateDebtAuth(expected_debt_datum)) = + utils.to_monodatum(raw_gov_datum) + let debt_output = unsafe.list_at(outputs, expected_output_idx) + let expected_datum = + types.TreasuryDatum { + treas: types.TreasuryWithDebt { + debt: expected_debt_datum, + creation_time: None, + }, + } + + expect [ + types.ParamsData { + synthetic: params_synth, + params: types.LiveParams{params: types.ActiveParams { + collateral_assets, + .. + }}, + }, + ] = utils.params_from_refs(reference_inputs, mint_script_hash) + and { + // Output is a valid datum at the correct address: + (debt_output.address == Address( + state_cred, + Some(Inline(ScriptCredential(mint_script_hash))), + ))?, + (debt_output.datum == InlineDatum(expected_datum))?, + // Gov lock token isn't burned, it's transferred to the created debt + (value.quantity_of( + debt_output.value, + mint_script_hash, + types.gov_lock_token_name, + ) == 1)?, + utils.mints_nothing_here(minted, mint_script_hash)?, + // Gov input contains lock token + utils.gov_has_lock_token(gov_inp, mint_script_hash)?, + // Synthetic name matches with referenced params + (params_synth == expected_debt_datum.asset)?, + // All tokens in the debt value are valid collateral assets + { + let p, a, _, acc <- value.reduce(debt_output.value, True) + acc && or { + p == mint_script_hash && a == types.gov_lock_token_name, + list.has( + collateral_assets, + types.AssetClass { policy_id: p, asset_name: a }, + ), + } + }?, + } +} + +// If the treasury has debt, anybody can redeem it. It's assumed that the debt was created via a gov action or via bad debt and therefore redemptions against it are authorized +pub fn treasury_redeem( + inputs, + reference_inputs, + redeemers, + mint, + outputs, + validity_range, + state_script_hash: ByteArray, + mint_script_hash: types.ScriptHash, + price_feed_script_hash: ByteArray, + lock_burned: Option, + inputs_from_fees: Bool, +) { + let state_cred = ScriptCredential(state_script_hash) + expect Interval { + lower_bound: IntervalBound { bound_type: Finite(valid_from), .. }, + .. + } = validity_range + let inputs_here = + list.filter( + inputs, + fn(i: Input) { i.output.address.payment_credential == state_cred }, + ) + + let minted_here = + mint + |> value.from_minted_value + |> value.tokens(mint_script_hash) + |> dict.to_pairs + + let (Pair(burn_token, burn_amount_neg), lock_mint_pair) = + when lock_burned is { + Some(burned_first) -> { + expect [a, b] = minted_here + if burned_first { + (b, Some(a)) + } else { + (a, Some(b)) + } + } + None -> { + expect [a] = minted_here + (a, None) + } + } + let burn_amount = -burn_amount_neg + let (debt_value_with_lock, debt_amount, num_lock_spent) = { + let + i, + (acc_debt_value, acc_debt, acc_lock_spent), + <- list.foldl(inputs_here, (value.zero(), 0, 0)) + expect InlineDatum(input_datum) = i.output.datum + expect types.TreasuryDatum { treas }: types.MonoDatum = input_datum + when treas is { + types.TreasuryWithDebt { + debt: types.TreasuryDebt { amount: this_debt_amount, asset: debt_asset }, + creation_time, + } -> { + expect and { + debt_asset == burn_token, + when creation_time is { + Some(creation_time_num) -> + creation_time_num + types.milliseconds_in_week <= valid_from + None -> True + }, + } + ( + value.merge(acc_debt_value, i.output.value), + acc_debt + this_debt_amount, + // We expect that all TreasuryWithDebt inputs have a lock token + acc_lock_spent + 1, + ) + } + _ -> { + expect + if inputs_from_fees { + treas == types.TreasuryFromFees + } else { + treas == types.TreasuryOnlyRedeem + } + (value.merge(acc_debt_value, i.output.value), acc_debt, acc_lock_spent) + } + } + } + let debt_value = + value.add( + debt_value_with_lock, + mint_script_hash, + types.gov_lock_token_name, + -num_lock_spent, + ) + expect [ + types.ParamsData { + params: types.LiveParams{params: types.ActiveParams { + weights: cdp_weights, + collateral_assets: cdp_assets, + denominator: cdp_denominator, + max_proportions: cdp_max_proportions, + redemption_share, + .. + }}, + synthetic: synthetic_name, + }, + ] = utils.params_from_refs(reference_inputs, mint_script_hash) + let price_feed_redeemer = + utils.to_pricefeedredeemer( + unsafe.unsome( + pairs.get_first( + redeemers, + WithdrawFrom(utils.stake_cred_from_hash(price_feed_script_hash)), + ), + ), + ) + expect [ + types.PriceFeed { + collateral_prices: p_list, + denominator: p_denom, + synthetic: price_feed_synthetic, + validity: p_validity_range, + }, + ]: List = list.map(price_feed_redeemer, fn(p) { p.data }) + + // Tx validity range is a subset of the price feed's validity range + expect utils.check_price_feed_validity(p_validity_range, validity_range) + + let + debt_collateral_value, + _, + <- + prices.get_collateral_finances( + p_list, + p_denom, + debt_value, + cdp_assets, + cdp_weights, + cdp_denominator, + cdp_max_proportions, + // We only care about the value so we can ignore the synthetic amount + 1, + ) + let debt_collateral_value = debt_collateral_value |> rational.truncate + let redeeming_collateral_value = + burn_amount * redemption_share / types.bp_precision + let leftover_debt = debt_amount - burn_amount + let leftover_collateral_value = + debt_collateral_value - redeeming_collateral_value + + let (valid_change_value, remaining_outputs) = + if leftover_collateral_value > 0 { + expect Output { + datum: InlineDatum(change_datum_raw), + value: change_value, + address: Address { + payment_credential: ScriptCredential(change_script_hash), + .. + }, + .. + } = builtin.head_list(outputs) + + expect types.TreasuryDatum { treas }: types.MonoDatum = change_datum_raw + let valid_change_datum = + if inputs_from_fees { + (treas == types.TreasuryFromFees)? + } else { + (treas == types.TreasuryOnlyRedeem)? + } + + let + change_collateral_value_rat, + _, + <- + prices.get_collateral_finances( + p_list, + p_denom, + change_value, + cdp_assets, + cdp_weights, + cdp_denominator, + cdp_max_proportions, + // We only care about the value so we can ignore the synthetic amount + 1, + ) + + let change_collateral_value = + change_collateral_value_rat |> rational.truncate + + (and { + (change_script_hash == state_script_hash)?, + (change_collateral_value >= leftover_collateral_value)?, + valid_change_datum?, + { + let pol, tok, qty, acc <- value.reduce(change_value, True) + (acc && value.quantity_of(debt_value, pol, tok) >= qty)? + }, + }, builtin.tail_list(outputs)) + } else { + (True, outputs) + } + + let valid_leftover_debt = + if leftover_debt > 0 { + expect Output { + datum: InlineDatum(change_debt_datum_raw), + value: change_debt_value, + address: Address { + payment_credential: ScriptCredential(change_debt_script_hash), + .. + }, + .. + } = builtin.head_list(remaining_outputs) + expect types.TreasuryDatum{treas: types.TreasuryWithDebt { + debt: types.TreasuryDebt { + amount: change_debt_amount, + asset: change_debt_asset, + }, + creation_time, + }}: types.MonoDatum = change_debt_datum_raw + and { + (creation_time == None)?, + (change_debt_amount == leftover_debt)?, + (change_debt_asset == burn_token)?, + (value.without_lovelace(change_debt_value) == value.from_asset( + mint_script_hash, + types.gov_lock_token_name, + 1, + ))?, + (value.lovelace_of(change_debt_value) <= types.max_min_ada)?, + (change_debt_script_hash == state_script_hash)?, + or { + num_lock_spent == 1, + // If there's leftover debt, we reserve one lock token for it + lock_mint_pair == Some( + Pair(types.gov_lock_token_name, -(num_lock_spent - 1)), + ), + }?, + } + } else { + // If there's no leftover debt, all lock tokens in the inputs must be burned + (lock_mint_pair == Some(Pair(types.gov_lock_token_name, -num_lock_spent)))? + } + + and { + valid_change_value?, + valid_leftover_debt?, + (synthetic_name == burn_token)?, + (price_feed_synthetic == burn_token)?, + (leftover_collateral_value >= 0)?, + (leftover_debt >= 0)?, + } +} + +pub fn treasury_stake_update( + inputs, + mint, + certificates, + state_hash: ByteArray, + mint_script_hash: types.ScriptHash, +) { + let minted = value.from_minted_value(mint) + let state_cred = ScriptCredential(state_hash) + expect [gov_inp] = + list.filter( + inputs, + fn(i: Input) { i.output.address.payment_credential == state_cred }, + ) + expect InlineDatum(raw_gov_datum) = gov_inp.output.datum + expect types.GovDatum(types.TreasuryStakeUpdate { delegatee: gov_delegatee }): types.MonoDatum = + raw_gov_datum + and { + certificates == [ + CredentialDelegation { + delegator: Inline(ScriptCredential(mint_script_hash)), + delegatee: gov_delegatee, + }, + ], + utils.only_mints_this( + minted, + mint_script_hash, + types.gov_lock_token_name, + -1, + )?, + // Gov input contains lock token + utils.gov_has_lock_token(gov_inp, mint_script_hash)?, + } +} + +pub fn treasury_reward_withdraw( + inputs, + mint, + outputs, + withdrawals, + state_hash: ByteArray, + mint_script_hash: types.ScriptHash, +) { + let state_cred = ScriptCredential(state_hash) + expect [] = + list.filter( + inputs, + fn(i: Input) { i.output.address.payment_credential == state_cred }, + ) + expect [ + Output { + value: withdrawn_value, + datum: InlineDatum(raw_withdraw_datum), + address: withdraw_address, + reference_script: None, + }, + .. + ] = outputs + expect withdraw_datum: types.MonoDatum = raw_withdraw_datum + expect Some(withdrawn_lovelace) = + withdrawals |> pairs.get_first(Inline(ScriptCredential(mint_script_hash))) + and { + utils.mints_nothing_here(mint |> value.from_minted_value, mint_script_hash), + withdraw_address == Address( + ScriptCredential(state_hash), + Some(Inline(ScriptCredential(mint_script_hash))), + ), + withdraw_datum == types.TreasuryDatum { treas: types.TreasuryFromFees }, + withdrawn_value == value.from_lovelace(withdrawn_lovelace), + } +} diff --git a/lib/butane/subvalidators/voided_synth.ak b/lib/butane/subvalidators/voided_synth.ak new file mode 100644 index 0000000..d4eff3a --- /dev/null +++ b/lib/butane/subvalidators/voided_synth.ak @@ -0,0 +1,70 @@ +use aiken/interval.{Finite, Interval, IntervalBound} +use aiken/list +use aiken/transaction.{InlineDatum, Input, Output} +use aiken/transaction/credential.{Address, ScriptCredential} +use aiken/transaction/value +use butane/types.{CDPCredentialVerifier} +use butane/utils + +pub fn collect_voided_cdp( + verifier: CDPCredentialVerifier, + mint, + extra_signatories, + inputs, + withdrawals, + validity_range, + reference_inputs, + mint_script_hash, + state_script_hash, +) { + expect [types.ParamsData { params, synthetic }] = + utils.params_from_refs(reference_inputs, mint_script_hash) + + let state_cred = ScriptCredential(state_script_hash) + + expect [(cdp_owner, cdp_oref)] = { + let acc, inp <- list.reduce(inputs, []) + let Input { + output: Output { + address: Address { payment_credential: inp_cred, .. }, + datum, + .. + }, + .. + } = inp + if inp_cred == state_cred { + expect InlineDatum(this_raw_datum) = datum + expect types.CDP { synthetic_asset: cdp_synthetic_name, owner, .. } = + utils.to_monodatum(this_raw_datum) + expect cdp_synthetic_name == synthetic + [(owner, inp.output_reference), ..acc] + } else { + acc + } + } + + expect Interval { + lower_bound: IntervalBound { bound_type: Finite(valid_from), .. }, + upper_bound: IntervalBound { bound_type: Finite(valid_to), .. }, + } = validity_range + + and { + utils.authorization_check( + fcredential: cdp_owner, + this_oref: cdp_oref, + verifier: verifier, + extra_signatories: extra_signatories, + inputs: inputs, + withdrawals: withdrawals, + valid_from: valid_from, + valid_to: valid_to, + )?, + (params == types.VoidedParams)?, + utils.only_mints_this( + value.from_minted_value(mint), + mint_script_hash, + types.cdp_lock_token_name, + -1, + ), + } +} diff --git a/lib/butane/tests/create_cdp.ak b/lib/butane/tests/create_cdp.ak new file mode 100644 index 0000000..0174d20 --- /dev/null +++ b/lib/butane/tests/create_cdp.ak @@ -0,0 +1,297 @@ +use aiken/dict +use aiken/fuzz +use aiken/list +use aiken/transaction.{InlineDatum, Output} +use aiken/transaction/credential.{Address, Inline, ScriptCredential} +use aiken/transaction/value +use butane/subvalidators/cdp_script.{create_transitions} +use butane/tests/fuzzers +use butane/tests/utils.{ + change_output, fake_mint_hash, fake_state_hash, usd_params, usd_price, +} +use butane/types + +test create_0() { + create_transitions( + fake_mint_hash, + fake_state_hash, + 0, + 0, + [], + [], + [], + [change_output()], + dict.new(), + 0, + value.zero(), + 0, + ) == (types.StateDelta(dict.new(), 0, value.zero(), 0), change_output()) +} + +test create_1() { + let cdp_datum = + types.CDP { + owner: types.AuthorizeWithConstraint( + types.MustWithdrawFrom(Inline(ScriptCredential(fake_state_hash))), + ), + synthetic_asset: "USD", + synthetic_amount: 10, + start_time: 1000, + } + let cdp_datum: Data = cdp_datum + let cdp_output = + Output { + value: value.add(value.from_lovelace(10000000), fake_state_hash, "", 1), + address: Address(ScriptCredential(fake_state_hash), None), + datum: InlineDatum(cdp_datum), + reference_script: None, + } + create_transitions( + fake_mint_hash, + fake_state_hash, + 1000, + 3000, + [usd_params()], + [usd_price()], + [1], + [cdp_output, change_output()], + dict.new(), + 0, + value.zero(), + 0, + ) == ( + types.StateDelta(dict.insert(dict.new(), #"555344", 10), 0, value.zero(), 1), + change_output(), + ) +} + +fn push_to_tail(list: List, element: a) -> List { + when list is { + [] -> + [element] + [head, ..tail] -> + [head, ..push_to_tail(tail, element)] + } +} + +test create_composition_simple() { + let cdp_datum = + types.CDP { + owner: types.AuthorizeWithConstraint( + types.MustWithdrawFrom(Inline(ScriptCredential(fake_state_hash))), + ), + synthetic_asset: "USD", + synthetic_amount: 10, + start_time: 1000, + } + let cdp_datum: Data = cdp_datum + let cdp_outputs = + [ + Output { + value: value.add(value.from_lovelace(10000000), fake_state_hash, "", 1), + address: Address(ScriptCredential(fake_state_hash), None), + datum: InlineDatum(cdp_datum), + reference_script: None, + }, + Output { + value: value.add(value.from_lovelace(10000000), fake_state_hash, "", 1), + address: Address(ScriptCredential(fake_state_hash), None), + datum: InlineDatum(cdp_datum), + reference_script: None, + }, + ] + create_transitions( + fake_mint_hash, + fake_state_hash, + 1000, + 3000, + [usd_params()], + [usd_price()], + [list.length(cdp_outputs)], + push_to_tail(cdp_outputs, change_output()), + dict.new(), + 0, + value.zero(), + 0, + ).1st == { + let sds = { + let cdp_output <- list.map(cdp_outputs) + create_transitions( + fake_mint_hash, + fake_state_hash, + 1000, + 3000, + [usd_params()], + [usd_price()], + [1], + [cdp_output, change_output()], + dict.new(), + 0, + value.zero(), + 0, + ).1st + } + let + types.StateDelta { + mint: x_mint, + btn_delta: x_delta, + fee: x_fee, + lock_mints: x_lock, + }, + types.StateDelta { + mint: y_mint, + btn_delta: y_delta, + fee: y_fee, + lock_mints: y_lock, + }, + <- + list.reduce( + sds, + types.StateDelta { + mint: dict.new(), + btn_delta: 0, + fee: value.zero(), + lock_mints: 0, + }, + ) + types.StateDelta { + mint: dict.union_with( + x_mint, + y_mint, + fn(_, q0, q1) { + let q = q0 + q1 + if q == 0 { + None + } else { + Some(q) + } + }, + ), + btn_delta: x_delta + y_delta, + fee: value.merge(x_fee, y_fee), + lock_mints: x_lock + y_lock, + } + } +} + +fn create_composition_fuzzes() { + let fake_mint_hash <- fuzz.and_then(fuzzers.script_hash()) + let fake_state_hash <- fuzz.and_then(fuzzers.script_hash()) + let cdp_outputs <- + fuzz.map( + fuzz.list( + fuzzers.overcollateralised_cdp( + fake_mint_hash, + fake_state_hash, + usd_params(), + usd_price(), + ), + ), + ) + (fake_mint_hash, fake_state_hash, cdp_outputs) +} + +test create_composition(stuff via create_composition_fuzzes()) { + let (fake_mint_hash, fake_state_hash, cdp_outputs) = stuff + create_transitions( + fake_mint_hash, + fake_state_hash, + 1000, + 3000, + [usd_params()], + [usd_price()], + [list.length(cdp_outputs)], + push_to_tail(cdp_outputs, change_output()), + dict.new(), + 0, + value.zero(), + 0, + ).1st == { + let sds = { + let cdp_output <- list.map(cdp_outputs) + create_transitions( + fake_mint_hash, + fake_state_hash, + 1000, + 3000, + [usd_params()], + [usd_price()], + [1], + [cdp_output, change_output()], + dict.new(), + 0, + value.zero(), + 0, + ).1st + } + let + types.StateDelta { + mint: x_mint, + btn_delta: x_delta, + fee: x_fee, + lock_mints: x_lock, + }, + types.StateDelta { + mint: y_mint, + btn_delta: y_delta, + fee: y_fee, + lock_mints: y_lock, + }, + <- + list.reduce( + sds, + types.StateDelta { + mint: dict.new(), + btn_delta: 0, + fee: value.zero(), + lock_mints: 0, + }, + ) + types.StateDelta { + mint: dict.union_with( + x_mint, + y_mint, + fn(_, q0, q1) { + let q = q0 + q1 + if q == 0 { + None + } else { + Some(q) + } + }, + ), + btn_delta: x_delta + y_delta, + fee: value.merge(x_fee, y_fee), + lock_mints: x_lock + y_lock, + } + } +} + +test undercollateralised_fails( + cdp_outputs via fuzz.list_at_least( + fuzzers.undercollateralised_cdp( + fake_mint_hash, + fake_state_hash, + usd_params(), + usd_price(), + ), + 1, + ), +) fail { + let (a, _b) = + create_transitions( + fake_mint_hash, + fake_state_hash, + 1000, + 3000, + [usd_params()], + [usd_price()], + [list.length(cdp_outputs)], + push_to_tail(cdp_outputs, change_output()), + dict.new(), + 0, + value.zero(), + 0, + ) + a.lock_mints >= 0 +} diff --git a/lib/butane/tests/fuzzers.ak b/lib/butane/tests/fuzzers.ak new file mode 100644 index 0000000..814585f --- /dev/null +++ b/lib/butane/tests/fuzzers.ak @@ -0,0 +1,87 @@ +use aiken/fuzz +use aiken/list +use aiken/transaction.{InlineDatum, Output} +use aiken/transaction/credential.{Address, Inline, ScriptCredential} +use aiken/transaction/value.{Value} +use butane/types + +pub fn script_hash() -> Fuzzer { + fuzz.bytearray() +} + +pub fn collateral( + params: types.ParamsData, + prices: types.PriceFeed, +) -> Fuzzer<(Value, Int)> { + expect types.LiveParams{params: types.ActiveParams { + collateral_assets, + weights, + denominator: param_denom, + .. + }} = params.params + let types.PriceFeed { collateral_prices, denominator: price_denom, .. } = + prices + let index <- + fuzz.and_then(fuzz.int_between(0, list.length(collateral_assets) - 1)) + expect Some(collateral) = collateral_assets |> list.at(index) + expect Some(price) = collateral_prices |> list.at(index) + expect Some(weight) = weights |> list.at(index) + let c <- fuzz.and_then(fuzz.int_between(1000000, 100000000)) + let bc = price * c * param_denom / weight / price_denom + fuzz.constant( + (value.from_asset(collateral.policy_id, collateral.asset_name, c), bc), + ) +} + +pub fn overcollateralised_cdp( + fake_mint_hash: ByteArray, + fake_state_hash: ByteArray, + params: types.ParamsData, + prices: types.PriceFeed, +) -> Fuzzer { + let cr <- fuzz.and_then(fuzz.int_between(11000, 14000)) + do_cdp(fake_mint_hash, fake_state_hash, params, prices, cr) +} + +pub fn undercollateralised_cdp( + fake_mint_hash: ByteArray, + fake_state_hash: ByteArray, + params: types.ParamsData, + prices: types.PriceFeed, +) -> Fuzzer { + let cr <- fuzz.and_then(fuzz.int_between(6000, 9000)) + do_cdp(fake_mint_hash, fake_state_hash, params, prices, cr) +} + +pub fn do_cdp( + fake_mint_hash: ByteArray, + fake_state_hash: ByteArray, + params: types.ParamsData, + prices: types.PriceFeed, + cr: Int, +) { + let (val, mint) <- fuzz.and_then(collateral(params, prices)) + let owner_hash <- fuzz.and_then(script_hash()) + let cdp_datum = + types.CDP { + owner: types.AuthorizeWithConstraint( + types.MustWithdrawFrom(Inline(ScriptCredential(owner_hash))), + ), + synthetic_asset: "USD", + synthetic_amount: mint * 10000 / cr, + start_time: 1000, + } + fuzz.constant( + Output { + value: value.add( + value.merge(value.from_lovelace(0), val), + fake_mint_hash, + "", + 1, + ), + address: Address(ScriptCredential(fake_state_hash), None), + datum: InlineDatum(cdp_datum), + reference_script: None, + }, + ) +} diff --git a/lib/butane/tests/repay_cdp.ak b/lib/butane/tests/repay_cdp.ak new file mode 100644 index 0000000..acf633d --- /dev/null +++ b/lib/butane/tests/repay_cdp.ak @@ -0,0 +1,101 @@ +use aiken/dict +use aiken/transaction.{ + InlineDatum, Input, Output, OutputReference, TransactionId, +} +use aiken/transaction/credential.{Address, ScriptCredential} +use aiken/transaction/value +use butane/subvalidators/cdp_script.{spend_transitions} +use butane/tests/utils.{ + fake_asset_class, fake_leftovers_hash, fake_mint_hash, fake_state_hash, +} +use butane/types + +test spend_0() { + spend_transitions( + [], + [], + fake_state_hash, + fake_mint_hash, + fake_leftovers_hash, + 10_000, + 1000, + [], + [], + fake_asset_class(), + fn() { True }, + [], + [], + [], + dict.new(), + 0, + value.zero(), + 0, + fn(_a, _b, _c, _d, _e) { 0 }, + ) == 0 +} + +test spend_1() { + let cdp_datum = + types.CDP { + owner: types.AuthorizeWithPubKey("", ""), + synthetic_asset: "USD", + synthetic_amount: 20000000, + start_time: 1000, + } + let cdp_datum: Data = cdp_datum + let cdp_input = + Input { + output: Output { + value: value.add(value.from_lovelace(10000000), fake_state_hash, "", 1), + address: Address(ScriptCredential(fake_state_hash), None), + datum: InlineDatum(cdp_datum), + reference_script: None, + }, + output_reference: OutputReference { + transaction_id: TransactionId(""), + output_index: 0, + }, + } + + let + _remaining_outputs, + x_mint, + x_btn_delta, + x_fee, + x_lock_mints, + <- + spend_transitions( + [cdp_input], + [], + fake_state_hash, + fake_mint_hash, + fake_leftovers_hash, + 1000, + 100, + [""], + [], + fake_asset_class(), + fn() { True }, + [utils.usd_params()], + [utils.usd_price()], + [ + types.SpendAction { + spend_type: types.RepayCDP( + types.AuthorizingDirectly(types.AuthorizedWithExtraSigs), + ), + params_idx: 0, + fee_type: types.FeeInSynthetic, + }, + ], + dict.new(), + 0, + value.zero(), + 0, + ) + and { + x_mint == dict.insert(dict.new(), #"555344", -20000000), + x_btn_delta == 0, + x_fee == value.zero(), + x_lock_mints == -1, + } +} diff --git a/lib/butane/tests/utils.ak b/lib/butane/tests/utils.ak new file mode 100644 index 0000000..0f31001 --- /dev/null +++ b/lib/butane/tests/utils.ak @@ -0,0 +1,67 @@ +use aiken/interval.{Finite, Interval, IntervalBound} +use aiken/transaction.{NoDatum, Output} +use aiken/transaction/credential.{Address, ScriptCredential} +use aiken/transaction/value +use butane/types + +pub const fake_mint_hash = + #"4fe5fcedb7f1061f9e9c25d1811cba7a5b452be6a3669a8b81e1ac0a44aa3f9e" + +pub const fake_state_hash = + #"4fe5fcedb7f1061f9e9c25d1811cba7a5b452be6a3669a8b81e1ac0a44aa3f9e" + +pub const fake_leftovers_hash = + #"4fe5fcedb7f1061f9e9c25d1811cba7a5b452be6a3669a8b81e1ac0a44aa3f9e" + +pub fn fake_asset_class() { + types.AssetClass { policy_id: "", asset_name: "" } +} + +pub fn change_output() { + Output { + value: value.zero(), + address: Address(ScriptCredential(""), None), + datum: NoDatum, + reference_script: None, + } +} + +pub fn usd_params() { + types.ParamsData { + params: types.LiveParams { + params: types.ActiveParams { + collateral_assets: [types.AssetClass("", "")], + weights: [110], + denominator: 100, + minimum_outstanding_synthetic: 0, + interest_rates: [(200, 200)], + max_proportions: [10000], + max_liquidation_return: 10000, + treasury_liquidation_share: 1, + redemption_share: 0, + fee_token_discount: 0, + staking_interest_rates: [(200, 200)], + }, + }, + synthetic: "USD", + } +} + +pub fn usd_price() { + types.PriceFeed { + collateral_prices: [100], + synthetic: "USD", + denominator: 1, + validity: non_interval(), + } +} + +pub fn non_interval() { + Interval { + lower_bound: IntervalBound { bound_type: Finite(2000), is_inclusive: False }, + upper_bound: IntervalBound { + bound_type: Finite(10000), + is_inclusive: False, + }, + } +} diff --git a/lib/butane/types.ak b/lib/butane/types.ak new file mode 100644 index 0000000..3b78207 --- /dev/null +++ b/lib/butane/types.ak @@ -0,0 +1,495 @@ +use aiken/dict.{Dict} +use aiken/hash.{Blake2b_224, Hash} +use aiken/time.{PosixTime} +use aiken/transaction.{Datum, OutputReference, ValidityRange} +use aiken/transaction/credential.{ + Address, PoolId, Script, StakeCredential, VerificationKey, +} +use aiken/transaction/value.{AssetName, PolicyId, Value} + +// Constants +/// Rough upper bound on minAda +pub const max_min_ada = 2_000_000 + +/// Name of the CDP lock tokens +pub const cdp_lock_token_name = "" + +// Name of the staking lock tokens +pub const staking_lock_token_name = "STK" + +/// Name of the governance lock token +pub const gov_lock_token_name = "gov" + +/// Prefix for all synthetic parameter tokens +pub const params_prefix = "p_" + +/// Length of params_prefix +pub const params_prefix_length = 2 + +/// Prefix for all aribtrary debt tokens +pub const debt_prefix = "d_" + +/// Length of debt_prefix +pub const debt_prefix_length = 2 + +/// The precision of basis points +pub const bp_precision = 10_000 + +/// Time constants +pub const milliseconds_in_year = 31_536_000_000 + +pub const milliseconds_in_week = 604_800_000 + +pub const milliseconds_in_day = 86_400_000 + +/// The number of historical interest rates stored in synthetic parameters +pub const num_stored_interest_rates = 71 + +pub type ControlAction { + InitMint + Upgrade +} + +// Helper types +/// Hash of a public key +pub type PubKeyHash = + Hash + +/// Hash of a script +pub type ScriptHash = + Hash + +/// A cardano asset +pub type AssetClass { + /// The policy id of the asset + policy_id: PolicyId, + /// The name of the asset + asset_name: AssetName, +} + +// Authorization types +/// The owner of a CDP +pub type CDPCredential { + /// Owner is a public key + AuthorizeWithPubKey(PubKeyHash, VerificationKey) + /// Owner is defined by a constraint + AuthorizeWithConstraint(Constraint) +} + +/// A constraint on a CDP +pub type Constraint { + /// The owner is defined by the spending of a token + MustSpendToken(AssetClass) + /// The owner is defined by a script that must be withdrawn from + MustWithdrawFrom(StakeCredential) +} + +pub type ConstraintCredential { + utxo: OutputReference, + interval: ValidityRange, + constraint: Constraint, +} + +pub type CDPSubVerifier { + AuthorizedWithExtraSigs + AuthorizedWithInputsOref(OutputReference) + AuthorizedWithWithdrawal +} + +pub type CDPCredentialVerifier { + AuthorizingDirectly(CDPSubVerifier) + AuthorizingOtherWithSignature { + other: ConstraintCredential, + sub_verifier: CDPSubVerifier, + signature: ByteArray, + } +} + +/// Keeps a running track of the expected state change at the end of a CDP transaction +pub type StateDelta { + mint: Dict, + btn_delta: Int, + fee: Value, + lock_mints: Int, +} + +/// Parameters with their associated synthetic named (grabbed from the value of the params UTxO) +pub type ParamsData { + params: Params, + synthetic: AssetName, +} + +/// Created debt that lets the treasury be redeemed by anyone who can burn the debt amount +pub type TreasuryDebt { + amount: Int, + asset: AssetName, +} + +/// Stores global configurations +pub type GlobalDatum { + /// The hash of the price feed script + price_feed_script_hash: ScriptHash, + /// Extra data used for governance. Ignored in these scripts + gov_data: Data, +} + +/// Datum used by all UTxOs as the CDP script +pub type MonoDatum { + /// Parameters for a given synthetic + ParamsWrapper { + /// The synthetic parameters + params: Params, + } + /// A CDP + CDP { + /// The owner of a CDP, can be a pubkey or a constraint + owner: CDPCredential, + /// The synthetic asset the CDP is minting + synthetic_asset: AssetName, + /// The amount of the synthetic minted + synthetic_amount: Int, + /// The opening time of the CDP, given as the lower bound of the validity range + start_time: PosixTime, + } + /// A governance action + GovDatum { + /// The governance action + gov: GovAction, + } + /// An output handled by the treasury + TreasuryDatum { + /// The type of treasury output + treas: TreasuryDatum, + } + /// Locked tokens for a future protocol version + CompatLockedTokens + StakedSynthetics { + owner: CDPCredential, + synthetic_asset: AssetName, + start_time: PosixTime, + } +} + +pub type TreasuryDatum { + /// i.e collected from the "BadDebtCollection" endpoint + TreasuryWithDebt { debt: TreasuryDebt, creation_time: Option } + /// Treasury deposited at genesis (cannot be collected via redemptions) + TreasuryFromGenesis + /// Treasury deposited via fees (can be spent via redemptions & governance) + TreasuryFromFees + /// Treasury that can only be used for redemptions + TreasuryOnlyRedeem + /// Treasury that can only be used for spending + TreasuryOnlySpend +} + +/// After a liquidation / a redemption with leftovers, orginal CDP owner can claim +pub type LeftoversDatum { + /// Owner of the leftovers, can be a pubkey or a constraint + owner: CDPCredential, +} + +/// Non-Opaque version of Output for blueprint +pub type FakeOutput { + address: Address, + value: List<(PolicyId, List<(AssetName, Int)>)>, + datum: Datum, + reference_script: Option>, +} + +/// The type of governance action +pub type GovAction { + /// Creation of new parameters for a synthetic + NewParamsAuth { + /// The new parameters + params: Params, + /// Synthetic asset name to create parameters for + asset: AssetName, + } + /// Updating of the parameters for a synthetic + UpdateParamsAuth { + /// Synthetic asset name to update parameters for + asset: AssetName, + /// The type of update + action: ParamAction, + } + /// Spending from the treasury + TreasurySpendAuth { + /// The output to create after spending + out: FakeOutput, + } + /// Minting assets via the treasury + TreasuryMintAuth { + /// The asset to mint + asset: AssetName, + /// The amount to mint + amount: Int, + } + /// Creating debt for the treasury that can be redeemed + TreasuryCreateDebtAuth { + /// Details of the debt + debt: TreasuryDebt, + } + /// Arbitrary text proposal + TextProposalAuth { + /// Details of the proposal + text: ByteArray, + } + /// Updating of the treasury's stake delegation + TreasuryStakeUpdate { + /// The pool to delegate to + delegatee: PoolId, + } + /// Deferring to an external script + ExternalScript { + /// Hash of the script + other_script: ScriptHash, + /// Additional data + other_data: Data, + } + /// Protocol upgrade, lets users lock and unlock tokens between protocol versions + GovNewCompat { upgrade_policy: PolicyId } +} + +pub type ActiveParams { + /// The ordered list of permitted collateral assets + collateral_assets: List, + /// The numerators of the collateral weights + weights: List, + /// The shared denominator of the collateral weights + denominator: Int, + /// Minimum amount of the synthetic each created CDP must mint + minimum_outstanding_synthetic: Int, + /// 10 most recent interest rates with timestamps, global max rate with timestamp 0 is final element. Rates are in basis points + interest_rates: List<(PosixTime, Int)>, + /// Maximum proportion of the debt that can be collateralized by the ith token + max_proportions: List, + /// The % value, in terms of the synthetic, that a liquidator can claim from the CDP during a liquidation + max_liquidation_return: Int, + /// What % of the excess in a liquidation goes to the treasury + treasury_liquidation_share: Int, + /// What % of the repayed value in a redemption can the redeemer claim + redemption_share: Int, + /// What % discount users get on interest repayments when they use the protocol fee token (BTN) + fee_token_discount: Int, + /// 10 most recent interest rates with timestamps, global max rate with timestamp 0 is final element. Rates are in basis points + staking_interest_rates: List<(PosixTime, Int)>, +} + +pub type Params { + LiveParams { params: ActiveParams } + /// This transition happens if the price feed denominator is ever 0 + VoidedParams +} + +pub type ParamAction { + /// Adding a new collateral asset + NewCollateral { + /// Index of the new asset in the ordered list of collateral assets + index: Int, + /// The new asset to add + collateral_asset: AssetClass, + /// The weight numerator of the new asset + weight_numerator: Int, + /// The weight denominator of the new asset + weight_denominator: Int, + /// The maximum proportion of the new asset + max_proportion: Int, + } + /// Updating the weight of an asset + UpdateWeight { + /// The index of the asset to update + collateral_asset_idx: Int, + /// The new weight numerator + weight_numerator: Int, + /// The new weight denominator + weight_denominator: Int, + } + /// Adding a new interest rate + UpdateInterest { + /// The new interest rate + interest_rate: Int, + } + /// Updating the minimum outstanding synthetic + UpdateMinOutstanding { + /// The new minimum outstanding synthetic + min_outstanding: Int, + } + /// Updating the maximum proportions + UpdateMaxProportions { + /// The new maximum proportions + max_proportions: List, + } + /// Updating the maximum liquidation return + UpdateMaxLiquidationReturn { + /// The new maximum liquidation return + max_return: Int, + } + /// Updating the treasury liquidation share + UpdateTreasuryLiquidationShare { + /// The new treasury liquidation share + share: Int, + } + /// Updating the redemption share + UpdateRedemptionShare { + /// The new redemption share + share: Int, + } + /// Updating the fee token discount + UpdateFeeTokenDiscount { + /// The new fee token discount + discount: Int, + } + /// Adding a new interest rate + UpdateStakingInterest { + /// The new interest rate + interest_rate: Int, + } +} + +// Redeemers +/// The type of the CDP spending action +pub type SpendType { + /// CDP Liquidations + LiquidateCDP + PartialLiquidateCDP { repay_amount: Int } + LeftoversLiquidateCDP + /// Repaying the debt of a CDP as an owner + RepayCDP(CDPCredentialVerifier) + /// Redeeming a CDP + RedeemCDP +} + +/// How the interest is being repayed +pub type FeeType { + /// Pay the fee in terms of the synthetic asset + FeeInSynthetic + /// Pay the fee in the protocol fee token + FeeInFeeToken { + /// The index of the fee token in the price feed list + fee_token_idx: Int, + } +} + +/// An action that spends a CDP output +pub type SpendAction { + /// The action type + spend_type: SpendType, + /// Index of the params reference input in the ordered list of params (i.e., excluding other reference inputs) + params_idx: Int, + /// How the user is paying the fee + fee_type: FeeType, +} + +/// Redeemer for forwards compatibility +pub type CompatRedeemer { + /// Locking future tokens and minting tokens for this protocol version + CompatLock { oidx: Int } + /// Unlocking future tokens and burning tokens from this protocol version + CompatUnlock { soidx: Option } +} + +/// Redeemer for the main script +pub type PolicyRedeemer { + /// CDP actions + SyntheticsMain { + /// The list of actions that spend a CDP + spends: List, + /// The list of creation actions. CDP creations are grouped by synthetic type in the same order as referenced param inputs + creates: List, + } + CollectVoidedCDP { verifier: CDPCredentialVerifier } + /// Treasury handling bad debt + BadDebt { + /// Leftover output index + treasury_out_idx: Int, + } + Auxilliary +} + +// Redeemer for the auxilliary script +pub type AuxilliaryRedeemer { + /// Creation of new params + ParamsMint { + /// Index of created output + oidx: Int, + } + /// Update of params + ParamsUpdate { + /// Index of updated output + oidx: Int, + /// Whether the spent gov UTxO comes before the spent params UTxO or not + reverse_order: Bool, + } + ParamsVoid + /// Creation of a governance action + GovernanceIssue { + /// Index of created governance action + output_idx: Int, + } + /// Invoking of external governance script + ExternalGovernance + /// Spending from treasury + TreasurySpend { + /// Index of the expected created output + expected_output_idx: Int, + /// In the list of inputs spent at the CDP script, the index of the governance action input + gov_inp_idx: Int, + /// Whether the treasury inputs to spend are from fees + inputs_from_fees: Bool, + } + /// Minting tokens from the treasury + TreasuryMint { + /// Index of the expected created output + expected_output_idx: Int, + } + + /// Creating debt for the treasury + TreasuryCreateDebt { + /// Index of the expected created output + expected_output_idx: Int, + } + /// Redeeming from the treasury + TreasuryRedemption { + // If burning created debt fully, whether the lock token comes first in the burned value. + // Set to None if not burning created debt fully + lock_burned: Option, + /// Whether the treasury inputs to redeem are from fees + inputs_from_fees: Bool, + } + TreasuryDelegate + TreasuryGetRewards + /// Compatibility with a different protocol version + Compatibility { inner: CompatRedeemer } + Staking(StakingRedeemer) +} + +pub type StakingRedeemer { + StakeSynthetics { staked_amount: Int } + UnstakeSynthetics { verifier: CDPCredentialVerifier } +} + +// Price feeds +/// Generic feed with data and extra information +pub type Feed { + /// Arbitrary data + data: x, + /// Extra information + extra: y, +} + +/// A price feed +pub type PriceFeed { + /// Numerators of prices of collateral tokens in terms of the synthetic + collateral_prices: List, + /// The synthetic associated with the price feed + synthetic: AssetName, + /// The denominator of the prices + denominator: Int, + /// Validity interval of the price feed + validity: ValidityRange, +} + +/// Redeemer for the price feed script, a list of price feeds and signatures, in the same order as the synthetic parameter reference inputs +pub type PriceFeedRedeemer = + List> diff --git a/lib/butane/unsafe.ak b/lib/butane/unsafe.ak new file mode 100644 index 0000000..9c29150 --- /dev/null +++ b/lib/butane/unsafe.ak @@ -0,0 +1,14 @@ +use aiken/builtin + +pub fn list_at(list: List, i: Int) { + if i == 0 { + builtin.head_list(list) + } else { + list_at(builtin.tail_list(list), i - 1) + } +} + +pub fn unsome(x: Option) -> a { + expect Some(inner) = x + inner +} diff --git a/lib/butane/utils.ak b/lib/butane/utils.ak new file mode 100644 index 0000000..f64642e --- /dev/null +++ b/lib/butane/utils.ak @@ -0,0 +1,447 @@ +use aiken/builtin +use aiken/bytearray +use aiken/dict.{Dict} +use aiken/interval.{Finite, Interval, IntervalBound} +use aiken/list +use aiken/math +use aiken/math/rational.{Rational} +use aiken/pairs +use aiken/time.{PosixTime} +use aiken/transaction.{InlineDatum, Input, Output, OutputReference} +use aiken/transaction/credential.{ + Credential, Inline, ScriptCredential, StakeCredential, +} +use aiken/transaction/value.{AssetName, PolicyId, Value, quantity_of} +use butane/types +use butane/unsafe + +pub fn lcm(a: Int, b: Int) { + a * b / math.gcd(a, b) +} + +/// Checks whether a given interval is a subset of the interval +pub fn contains_interval(self: Interval, interval: Interval) -> Bool { + interval.intersection(self, interval) == interval +} + +/// For a finite interval, returns the range of the interval +pub fn finite_interval_range(interval: Interval) -> Int { + expect Interval { + lower_bound: IntervalBound { bound_type: Finite(lb), .. }, + upper_bound: IntervalBound { bound_type: Finite(ub), .. }, + } = interval + ub - lb +} + +pub fn check_price_feed_validity( + pf_validity: Interval, + tx_validity: Interval, +) { + and { + (finite_interval_range(pf_validity) < types.milliseconds_in_day)?, + contains_interval(pf_validity, tx_validity)?, + } +} + +pub fn until_input_from(cred: Credential, inputs: List) { + if builtin.head_list(inputs).output.address.payment_credential == cred { + inputs + } else { + until_input_from(cred, builtin.tail_list(inputs)) + } +} + +pub fn gov_has_lock_token(gov_inp: Input, own_hash: types.ScriptHash) { + gov_inp.output.value + |> value.tokens(own_hash) + |> dict.has_key(types.gov_lock_token_name) +} + +/// Ensures that nothing is minted at the given policy +pub fn mints_nothing_here(v: Value, p: PolicyId) { + builtin.null_list(value.tokens(v, p) |> dict.to_pairs) +} + +/// Ensures that the only token minted at this policy is the given asset name with the given amount +pub fn only_mints_this(v: Value, p: PolicyId, n: AssetName, a: Int) { + ( value.tokens(v, p) |> dict.to_pairs ) == [Pair(n, a)] +} + +pub fn until_zero(remaining: Int, acc: a, with: fn(a) -> a) -> a { + if remaining == 0 { + acc + } else { + until_zero(remaining - 1, with(acc), with) + } +} + +pub fn compare_asset_classes( + a: types.AssetClass, + b: types.AssetClass, +) -> Ordering { + when bytearray.compare(a.policy_id, b.policy_id) is { + Equal -> bytearray.compare(a.asset_name, b.asset_name) + x -> x + } +} + +pub fn list_insert_at(l: List, idx: Int, el: a) -> List { + if l == [] { + [el] + } else if idx == 0 { + [el, ..l] + } else { + [builtin.head_list(l), ..list_insert_at(builtin.tail_list(l), idx - 1, el)] + } +} + +test list_insert_at_1() { + let l = + [1, 2, 3] + let l2 = list_insert_at(l, 1, 4) + l2 == [1, 4, 2, 3] +} + +test list_insert_at_2() { + let l = + [1, 2, 3] + let l2 = list_insert_at(l, 0, 4) + l2 == [4, 1, 2, 3] +} + +test list_insert_at_3() { + let l = + [1, 2, 3] + let l2 = list_insert_at(l, 3, 4) + l2 == [1, 2, 3, 4] +} + +test list_insert_at_4() { + let l = + [1, 2, 3] + let l2 = list_insert_at(l, 4, 4) + l2 == [1, 2, 3, 4] +} + +pub fn sorted_list_is_unique(l: List) -> Bool { + when l is { + [x, y, ..xs] -> + if x == y { + False + } else { + sorted_list_is_unique([y, ..xs]) + } + _ -> True + } +} + +test sorted_list_is_unique_1() { + let l = + [1, 2, 3] + sorted_list_is_unique(l) +} + +test sorted_list_is_unique_2() { + let l = + [1, 2, 2, 3] + !sorted_list_is_unique(l) +} + +// Returns the fee percentage in basis points +pub fn calculate_fee_percent( + interest_rates: List<(PosixTime, Int)>, + cdp_start_time: PosixTime, + close_time: PosixTime, +) -> Int { + do_calculate_fee_percent(0, close_time, interest_rates, cdp_start_time) +} + +pub fn do_calculate_fee_percent( + acc_percent: Int, + prev_time: PosixTime, + interest_rates: List<(PosixTime, Int)>, + cdp_start_time: PosixTime, +) { + let (time_neg, rate) = builtin.head_list(interest_rates) + let time = -time_neg + if time < cdp_start_time { + let time_diff = prev_time - cdp_start_time + let new_acc = acc_percent + rate * time_diff + new_acc + } else { + let time_diff = prev_time - time + let new_acc = acc_percent + rate * time_diff + do_calculate_fee_percent( + new_acc, + time, + builtin.tail_list(interest_rates), + cdp_start_time, + ) + } +} + +// Returns the fee percentage in basis points +pub fn calculate_earnings_percent( + interest_rates: List<(PosixTime, Int)>, + staking_start_time: PosixTime, + staking_end_time: PosixTime, +) -> Int { + do_calculate_earnings_percent( + 0, + staking_end_time, + interest_rates, + staking_start_time, + ) +} + +pub fn do_calculate_earnings_percent( + acc_percent: Int, + prev_time: PosixTime, + interest_rates: List<(PosixTime, Int)>, + staking_start_time: PosixTime, +) { + when interest_rates is { + [(time_neg, rate), ..xs] -> { + let time = -time_neg + if time < staking_start_time { + let time_diff = prev_time - staking_start_time + let new_acc = acc_percent + rate * time_diff + new_acc + } else { + let time_diff = prev_time - time + let new_acc = acc_percent + rate * time_diff + do_calculate_earnings_percent(new_acc, time, xs, staking_start_time) + } + } + _ -> acc_percent + } +} + +pub fn fake_to_real_out(in: types.FakeOutput) -> Output { + let types.FakeOutput { value, address, reference_script, datum } = in + Output { + address, + reference_script, + datum, + value: { + let (fst, snd), acc <- list.foldl(value, value.zero()) + let (fst2, snd2), acc <- list.foldl(snd, acc) + value.add(acc, fst, fst2, snd2) + }, + } +} + +pub fn get_state_delta( + synthetic_name: AssetName, + repaid_amount: Int, + fee_type: types.FeeType, + fee_in_synthetic: Int, + staking_interest_rates: List<(PosixTime, Int)>, + cdp_start_time: PosixTime, + cdp_close_time: PosixTime, + fee_token_discount: Int, + treasury_share: Value, + p_list: List, + p_denom: Int, + collateral_assets: List, + fee_token: types.AssetClass, + g: fn(Dict, Int, Value) -> a, +) { + when fee_type is { + types.FeeInSynthetic -> + g( + dict.new() + |> dict.insert(synthetic_name, -repaid_amount - fee_in_synthetic), + 0, + treasury_share, + ) + types.FeeInFeeToken { fee_token_idx } -> { + // The theoretical maximum staking rewards that could be earned if the repaid amount was staked for the entire duration + // The amount of rewards given in staking should never be higher than the required interest to be paid + let max_staking_rewards = + calculate_earnings_percent( + interest_rates: staking_interest_rates, + staking_start_time: cdp_start_time, + staking_end_time: cdp_close_time, + ) * repaid_amount / types.bp_precision / types.milliseconds_in_year + let price = unsafe.list_at(p_list, fee_token_idx) + expect unsafe.list_at(collateral_assets, fee_token_idx) == fee_token + let expected_fee = + math.max( + 0, + price * ( fee_in_synthetic - max_staking_rewards ) * ( + types.bp_precision - fee_token_discount + ) / types.bp_precision / p_denom, + ) + g( + dict.new() + |> dict.insert(synthetic_name, -(repaid_amount + max_staking_rewards)), + -expected_fee, + treasury_share, + ) + } + } +} + +pub fn get_treasury_share( + claimed_value: Value, + treasury_share_percent: Int, + cr: Rational, +) { + if rational.compare(cr, rational.from_int(1)) == Less { + value.zero() + } else { + let scale_num = rational.denominator(cr) + let scale_denom = rational.numerator(cr) + let p, n, amount, v <- value.reduce(claimed_value, value.zero()) + let excess_amount = amount - amount * scale_num / scale_denom + if excess_amount > 0 { + value.add( + v, + p, + n, + excess_amount * treasury_share_percent / types.bp_precision, + ) + } else { + v + } + } +} + +// Checks whether a given list of fractions with the same denominator could all be reduced with a smaller denominator +pub fn is_minimal_denom(nums: List, denom: Int) -> Bool { + list.foldl(nums, denom, fn(n, acc) { math.gcd(math.gcd(n, denom), acc) }) == 1 +} + +pub fn authorization_check( + fcredential: types.CDPCredential, + this_oref: OutputReference, + verifier: types.CDPCredentialVerifier, + extra_signatories: List, + inputs: List, + withdrawals: Pairs, + valid_from: Int, + valid_to: Int, +) { + when verifier is { + types.AuthorizingDirectly(cv2) -> + when cv2 is { + types.AuthorizedWithExtraSigs -> { + expect types.AuthorizeWithPubKey(hash, _) = fcredential + extra_signatories |> list.has(hash) + } + types.AuthorizedWithInputsOref(oref) -> { + expect types.AuthorizeWithConstraint(types.MustSpendToken(asset)) = + fcredential + let inp = + unsafe.unsome( + inputs |> list.find(fn(inp) { inp.output_reference == oref }), + ) + quantity_of(inp.output.value, asset.policy_id, asset.asset_name) >= 1 + } + types.AuthorizedWithWithdrawal -> { + expect types.AuthorizeWithConstraint(types.MustWithdrawFrom( + stake_cred, + )) = fcredential + withdrawals |> pairs.has_key(stake_cred) + } + } + types.AuthorizingOtherWithSignature { other, sub_verifier, signature } -> { + expect types.AuthorizeWithPubKey(_, key) = fcredential + let types.ConstraintCredential { utxo, interval, constraint } = other + expect Interval { + lower_bound: IntervalBound { bound_type: Finite(interval_lb), .. }, + upper_bound: IntervalBound { bound_type: Finite(interval_ub), .. }, + } = interval + and { + // This utxo is the utxo being spent + (utxo == this_oref)?, + (interval_lb <= valid_from)?, + (interval_ub >= valid_to)?, + // Validate signature of other + builtin.verify_ed25519_signature( + key, + builtin.serialise_data(other), + signature, + )?, + // Also validate other + authorization_check( + fcredential: types.AuthorizeWithConstraint(constraint), + this_oref: this_oref, + verifier: types.AuthorizingDirectly(sub_verifier), + extra_signatories: extra_signatories, + inputs: inputs, + withdrawals: withdrawals, + valid_from: valid_from, + valid_to: valid_to, + )?, + } + } + } +} + +pub fn not_withdrawing_from( + withdrawals: Pairs, + cred: StakeCredential, +) -> Bool { + !pairs.has_key(withdrawals, cred) +} + +pub fn withdraws_zero( + withdrawals: Pairs, + cred: StakeCredential, +) -> Bool { + pairs.get_first(withdrawals, cred) == Some(0) +} + +pub fn to_monodatum(raw: Data) -> types.MonoDatum { + expect parsed: types.MonoDatum = raw + parsed +} + +pub fn to_pricefeedredeemer(raw: Data) -> types.PriceFeedRedeemer { + expect parsed: types.PriceFeedRedeemer = raw + parsed +} + +pub fn params_from_refs(reference_inputs: List, own_hash: ByteArray) { + let ret_list, ref_input <- list.reduce(reference_inputs, []) + let Input { + output: Output { datum: ref_input_data, value: ref_input_value, .. }, + .. + } = ref_input + // We use invariant that any params tokens minted are the only Butane tokens in that utxo + when value.tokens(ref_input_value, own_hash) |> dict.to_pairs is { + [Pair(k, _)] -> + if bytearray.take(k, types.params_prefix_length) == types.params_prefix { + let params_synthetic_name = + bytearray.drop(k, types.params_prefix_length) + expect InlineDatum(params_data) = ref_input_data + expect types.ParamsWrapper(params_datum) = to_monodatum(params_data) + [ + types.ParamsData { + params: params_datum, + synthetic: params_synthetic_name, + }, + ..ret_list + ] + } else { + ret_list + } + _ -> ret_list + } +} + +pub fn stake_cred_from_hash(hash: ByteArray) { + Inline(ScriptCredential(hash)) +} + +pub fn find_input_with_credential( + inputs: List, + credential: Credential, +) -> Option { + list.find( + inputs, + fn(i: Input) { i.output.address.payment_credential == credential }, + ) +} diff --git a/validators/leftovers.ak b/validators/leftovers.ak new file mode 100644 index 0000000..70d3c6d --- /dev/null +++ b/validators/leftovers.ak @@ -0,0 +1,40 @@ +use aiken/interval.{Finite, Interval, IntervalBound} +use aiken/transaction.{ScriptContext, Spend, Transaction} +use butane/types +use butane/utils + +/// Validator to handle leftover collateral from CDP transactions +validator { + fn collect( + datum: types.LeftoversDatum, + redeemer: types.CDPCredentialVerifier, + ctx: ScriptContext, + ) -> Bool { + expect ScriptContext { + transaction: Transaction { + extra_signatories, + inputs, + withdrawals, + validity_range, + .. + }, + purpose: Spend(this_oref), + } = ctx + + expect Interval { + lower_bound: IntervalBound { bound_type: Finite(valid_from), .. }, + upper_bound: IntervalBound { bound_type: Finite(valid_to), .. }, + } = validity_range + + utils.authorization_check( + datum.owner, + this_oref, + redeemer, + extra_signatories, + inputs, + withdrawals, + valid_from, + valid_to, + )? + } +} diff --git a/validators/pointers.ak b/validators/pointers.ak new file mode 100644 index 0000000..0506d29 --- /dev/null +++ b/validators/pointers.ak @@ -0,0 +1,24 @@ +use aiken/pairs.{has_key} +use aiken/transaction.{ScriptContext} +use aiken/transaction/credential.{Credential, Referenced} +use butane/types + +validator(upgradeable_script_hash: Referenced) { + fn spend( + _datum: Data, + _redeemer: Data, + ctx: ScriptContext, + ) -> Bool { + has_key(ctx.transaction.withdrawals, upgradeable_script_hash) + } +} + +validator( + upgradeable_script_hash: Referenced, + /// Arbitrary number for mining to ensure that the minting policy is low (vanity reasons) + _salt: Int, +) { + fn mint(_redeemer: Data, ctx: ScriptContext) -> Bool { + has_key(ctx.transaction.withdrawals, upgradeable_script_hash) + } +} diff --git a/validators/price_feed.ak b/validators/price_feed.ak new file mode 100644 index 0000000..fcd406c --- /dev/null +++ b/validators/price_feed.ak @@ -0,0 +1,36 @@ +use aiken/builtin +use aiken/list +use aiken/transaction.{ScriptContext} +use butane/types + +/// Script for handling price feeds. Validates price feed signatures +validator( + /// The verification key for the price feed to validate signed feeds + verification_key: ByteArray, +) { + fn check_feed( + redeemer: Data, + _ctx: ScriptContext, + ) -> Bool { + expect redeemer: List> = redeemer + // verify each Data when serialised is signed via ByteArray + list.all( + redeemer, + fn(feed) { + let types.Feed { data, extra: signature } = feed + builtin.verify_ed25519_signature( + verification_key, + builtin.serialise_data(data), + signature, + )? + }, + ) + } +} + +// Only used to access the type in blueprints +validator { + fn feed_inner_type(_redeemer: types.PriceFeed, _ctx: ScriptContext) -> Bool { + False + } +} diff --git a/validators/synthetics.ak b/validators/synthetics.ak new file mode 100644 index 0000000..e936c3f --- /dev/null +++ b/validators/synthetics.ak @@ -0,0 +1,393 @@ +use aiken/builtin +use aiken/list +use aiken/option +use aiken/pairs +use aiken/transaction.{ScriptContext, Transaction, WithdrawFrom} +use aiken/transaction/credential.{Inline, ScriptCredential} +use butane/subvalidators/bad_debt.{bad_debt} +use butane/subvalidators/cdp_script.{cdp_script} +use butane/subvalidators/gov_issue.{consume_external, gov_issue} +use butane/subvalidators/next_compat.{compat_locking} +use butane/subvalidators/params_script.{params_init, params_update, params_void} +use butane/subvalidators/staking.{staking_script} +use butane/subvalidators/treasury.{ + treasury_create_debt, treasury_mint, treasury_redeem, treasury_reward_withdraw, + treasury_spend, treasury_stake_update, +} +use butane/subvalidators/voided_synth.{collect_voided_cdp} +use butane/types +use butane/utils + +/// Main validator which handles CDP creation/spending transactions +validator( + /// The token used for fees + fee_token: types.AssetClass, + /// The script hash where synthetics are minted from, and also delegation/withdrawals happen from + mint_script_hash: types.ScriptHash, + /// The script hash where protocol outputs are stored and spent from + spend_script_hash: types.ScriptHash, + /// The hash of the leftovers script + leftovers_script_hash: types.ScriptHash, + /// The NFT which authorizes CDP redemptions + redemptions_nft: types.AssetClass, + /// External script governance validation logic is deferred to + gov_script_hash: types.ScriptHash, + /// External script treasury validation logic is deferred to + treasury_script_hash: types.ScriptHash, + /// Hash of price feed validator + price_feed_script_hash: types.ScriptHash, + // External script stake validation logic is deferred to + stake_script_hash: types.ScriptHash, +) { + fn validate(redeemer: types.PolicyRedeemer, ctx: ScriptContext) -> Bool { + expect ScriptContext { + purpose: WithdrawFrom(_), + transaction: Transaction { + withdrawals, + redeemers, + inputs, + outputs, + reference_inputs, + mint, + validity_range, + extra_signatories, + certificates, + .. + }, + } = ctx + + let no_certs = + fn() { + let mint_stake_cred: Data = Inline(ScriptCredential(mint_script_hash)) + let certificate <- list.all(certificates) + // Can't do any certificate action that uses the mint script (delegator is always the first field) + ( builtin.un_constr_data(certificate).2nd |> builtin.head_list() ) != mint_stake_cred + } + + let zero_withdrawals = + fn() { + utils.not_withdrawing_from( + withdrawals, + Inline(ScriptCredential(mint_script_hash)), + ) + } + + let no_certs_or_withdrawals = + fn() { no_certs() && zero_withdrawals() } + + when redeemer is { + types.SyntheticsMain { spends, creates } -> + cdp_script( + mint, + outputs, + redeemers, + inputs, + reference_inputs, + extra_signatories, + validity_range, + withdrawals, + mint_script_hash, + spend_script_hash, + spends, + creates, + price_feed_script_hash, + leftovers_script_hash, + fee_token, + redemptions_nft, + ) && no_certs_or_withdrawals() + types.CollectVoidedCDP { verifier } -> + collect_voided_cdp( + verifier, + mint, + extra_signatories, + inputs, + withdrawals, + validity_range, + reference_inputs, + mint_script_hash, + spend_script_hash, + ) && no_certs_or_withdrawals() + types.BadDebt { treasury_out_idx } -> + bad_debt( + inputs, + outputs, + reference_inputs, + redeemers, + validity_range, + mint, + spend_script_hash, + price_feed_script_hash, + mint_script_hash, + treasury_out_idx, + ) && no_certs_or_withdrawals() + types.Auxilliary -> { + expect Some(aux_redeemer) = + redeemers + |> pairs.get_first( + WithdrawFrom(Inline(ScriptCredential(gov_script_hash))), + ) + |> option.or_try( + fn() { + pairs.get_first( + redeemers, + WithdrawFrom(Inline(ScriptCredential(treasury_script_hash))), + ) + }, + ) + |> option.or_try( + fn() { + pairs.get_first( + redeemers, + WithdrawFrom(Inline(ScriptCredential(stake_script_hash))), + ) + }, + ) + expect aux: types.AuxilliaryRedeemer = aux_redeemer + and { + (aux == types.TreasuryDelegate || no_certs())?, + (aux == types.TreasuryGetRewards || zero_withdrawals())?, + } + } + } + } +} + +/// External validator which handles governance redeemer endpoints +validator( + /// Token used for Butane governance + gov_nft: types.AssetClass, + /// The script hash where synthetics are minted from, and also delegation/withdrawals happen from + mint_script_hash: types.ScriptHash, + /// The script hash where protocol outputs are stored and spent from + spend_script_hash: ByteArray, + /// The token used for fees + fee_token: types.AssetClass, + /// Hash of price feed validator + price_feed_script_hash: types.ScriptHash, +) { + fn external_gov_validate( + aux_redeemer: types.AuxilliaryRedeemer, + ctx: ScriptContext, + ) { + expect ScriptContext { transaction, purpose: WithdrawFrom(..) } = ctx + let Transaction { + withdrawals, + redeemers, + inputs, + outputs, + reference_inputs, + mint, + validity_range, + .. + } = transaction + + when aux_redeemer is { + types.ParamsMint { oidx } -> + params_init( + mint, + outputs, + inputs, + validity_range, + mint_script_hash, + spend_script_hash, + oidx, + fee_token, + ) + types.ParamsUpdate { oidx, reverse_order } -> + params_update( + mint, + outputs, + validity_range, + inputs, + mint_script_hash, + spend_script_hash, + oidx, + reverse_order, + ) + types.ParamsVoid -> + params_void( + mint, + inputs, + redeemers, + outputs, + mint_script_hash, + price_feed_script_hash, + spend_script_hash, + validity_range, + ) + types.GovernanceIssue { output_idx } -> + gov_issue( + inputs, + outputs, + mint, + gov_nft, + spend_script_hash, + mint_script_hash, + output_idx, + ) + types.ExternalGovernance -> + consume_external( + inputs, + mint, + withdrawals, + spend_script_hash, + mint_script_hash, + ) + types.Compatibility { inner } -> + compat_locking( + inputs, + reference_inputs, + outputs, + mint, + spend_script_hash, + mint_script_hash, + inner, + ) + _ -> + fail @"Wrong auxilliary script -- use treasury/stake validator instead" + } + } +} + +/// External validator which handles treasury redeemer endpoints +validator( + /// The script hash where synthetics are minted from, and also delegation/withdrawals happen from + mint_script_hash: types.ScriptHash, + /// The script hash where protocol outputs are stored and spent from + spend_script_hash: ByteArray, + /// Hash of price feed validator + price_feed_script_hash: types.ScriptHash, +) { + fn external_treas_validate( + aux_redeemer: types.AuxilliaryRedeemer, + ctx: ScriptContext, + ) { + expect ScriptContext { transaction, purpose: WithdrawFrom(..) } = ctx + let Transaction { + withdrawals, + redeemers, + inputs, + outputs, + reference_inputs, + mint, + validity_range, + certificates, + .. + } = transaction + + when aux_redeemer is { + types.TreasurySpend { expected_output_idx, gov_inp_idx, inputs_from_fees } -> + treasury_spend( + inputs, + outputs, + mint, + spend_script_hash, + mint_script_hash, + expected_output_idx, + gov_inp_idx, + inputs_from_fees, + ) + types.TreasuryMint { expected_output_idx } -> + treasury_mint( + inputs, + outputs, + mint, + spend_script_hash, + mint_script_hash, + expected_output_idx, + ) + types.TreasuryCreateDebt { expected_output_idx } -> + treasury_create_debt( + inputs, + reference_inputs, + outputs, + mint, + spend_script_hash, + mint_script_hash, + expected_output_idx, + ) + types.TreasuryRedemption { lock_burned, inputs_from_fees } -> + treasury_redeem( + inputs, + reference_inputs, + redeemers, + mint, + outputs, + validity_range, + spend_script_hash, + mint_script_hash, + price_feed_script_hash, + lock_burned, + inputs_from_fees, + ) + types.TreasuryDelegate -> + treasury_stake_update( + inputs, + mint, + certificates, + spend_script_hash, + mint_script_hash, + ) + types.TreasuryGetRewards -> + treasury_reward_withdraw( + inputs, + mint, + outputs, + withdrawals, + spend_script_hash, + mint_script_hash, + ) + _ -> + fail @"Wrong auxilliary script -- use governance/stake validator instead" + } + } +} + +/// External validator which handles staking endpoints +validator( + /// The script hash where synthetics are minted from, and also delegation/withdrawals happen from + mint_script_hash: types.ScriptHash, + /// The script hash where protocol outputs are stored and spent from + spend_script_hash: ByteArray, +) { + fn external_staking_validate( + aux_redeemer: types.AuxilliaryRedeemer, + ctx: ScriptContext, + ) { + expect ScriptContext { transaction, purpose: WithdrawFrom(..) } = ctx + let Transaction { + withdrawals, + inputs, + outputs, + reference_inputs, + mint, + validity_range, + extra_signatories, + .. + } = transaction + + when aux_redeemer is { + types.Staking(staking_redeemer) -> + staking_script( + mint_script_hash, + spend_script_hash, + withdrawals, + inputs, + outputs, + reference_inputs, + mint, + validity_range, + extra_signatories, + staking_redeemer, + ) + _ -> + fail @"Wrong auxilliary script -- use treasury/governance validator instead" + } + } +} + +validator { + fn global_type(_d: types.GlobalDatum, _ctx) { + False + } +} diff --git a/validators/upgradeable.ak b/validators/upgradeable.ak new file mode 100644 index 0000000..355c202 --- /dev/null +++ b/validators/upgradeable.ak @@ -0,0 +1,107 @@ +use aiken/builtin +use aiken/dict +use aiken/list +use aiken/pairs +use aiken/transaction.{ + Input, Mint, OutputReference, ScriptContext, Transaction, WithdrawFrom, +} +use aiken/transaction/credential.{Inline, ScriptCredential} +use aiken/transaction/value +use butane/types + +pub fn find_input(el: Data, list: List) { + expect [head, ..tail] = list + let oref: Data = head.output_reference + if oref == el { + head + } else { + find_input(el, tail) + } +} + +validator( + init_utxo: OutputReference, + /// Arbitrary number for mining to ensure that the upgradeable validator comes before the price feeds validator in the withdrawals list + _salt: Int, +) { + fn control_state(redeemer: Data, ctx: ScriptContext) -> Bool { + let ScriptContext { transaction: tx, purpose } = ctx + + when purpose is { + WithdrawFrom(cred) -> { + expect Inline(ScriptCredential(own_hash)) = cred + let Transaction { reference_inputs, withdrawals, .. } = tx + let input = find_input(redeemer, reference_inputs) + expect [Pair(token_name, _)] = + input.output.value |> value.tokens(own_hash) |> dict.to_pairs + + let withdraw_cred = Inline(ScriptCredential(token_name)) + + pairs.has_key(withdrawals, withdraw_cred) + } + + Mint(own_policy) -> { + let Transaction { inputs, mint, .. } = tx + + expect redeemer: types.ControlAction = redeemer + + when redeemer is { + types.InitMint -> { + expect + list.any(inputs, fn(inp) { inp.output_reference == init_utxo }) + + expect [Pair(name, minted_amount)] = + mint + |> value.from_minted_value + |> value.tokens(own_policy) + |> dict.to_pairs + + // The new token must have a name of 28 bytes + // Expect one minted token + and { + minted_amount == 1, + builtin.length_of_bytearray(name) == 28, + } + } + + types.Upgrade -> { + // Expect one token minted and one token burned + // The new token must have a name of 28 bytes + expect [Pair(name1, minted_amount1), Pair(name2, minted_amount2)] = + mint + |> value.from_minted_value + |> value.tokens(own_policy) + |> dict.to_pairs + + or { + and { + minted_amount1 == 1, + minted_amount2 == -1, + builtin.length_of_bytearray(name1) == 28, + }, + and { + minted_amount1 == -1, + minted_amount2 == 1, + builtin.length_of_bytearray(name2) == 28, + }, + } + } + } + } + + _ -> False + } + } +} + +validator { + fn oref_type(_r: OutputReference, _ctx) { + False + } +} + +validator { + fn init_type(_r: types.ControlAction, _ctx) { + False + } +} diff --git a/validators/util.ak b/validators/util.ak new file mode 100644 index 0000000..d00f4fd --- /dev/null +++ b/validators/util.ak @@ -0,0 +1,13 @@ +use butane/types + +validator { + fn always_true(_d, _r, _ctx) { + True + } +} + +validator { + fn types(_r: types.ConstraintCredential, _ctx) { + True + } +} From 903e4642cf3d9174f06e09dbe543770d4f8c2b43 Mon Sep 17 00:00:00 2001 From: EzePze Date: Mon, 18 Nov 2024 17:19:16 +1100 Subject: [PATCH 2/2] Final Co-authored-by: Micah Kendall --- aiken.toml | 4 +- lib/butane/subvalidators/cdp_script.ak | 20 ++ lib/butane/subvalidators/params_script.ak | 5 +- lib/butane/subvalidators/staking.ak | 112 ++++---- lib/butane/tests/create_cdp.ak | 297 ---------------------- lib/butane/tests/fuzzers.ak | 87 ------- lib/butane/tests/repay_cdp.ak | 101 -------- lib/butane/tests/utils.ak | 67 ----- lib/butane/types.ak | 2 +- lib/butane/utils.ak | 10 + validators/pointers.ak | 2 +- 11 files changed, 96 insertions(+), 611 deletions(-) delete mode 100644 lib/butane/tests/create_cdp.ak delete mode 100644 lib/butane/tests/fuzzers.ak delete mode 100644 lib/butane/tests/repay_cdp.ak delete mode 100644 lib/butane/tests/utils.ak diff --git a/aiken.toml b/aiken.toml index c9af5cf..87f3f28 100644 --- a/aiken.toml +++ b/aiken.toml @@ -1,7 +1,7 @@ -name = "mcomp-tech/improved-spork" +name = "butaneprotocol/butane-contracts" version = "0.0.1" licenses = [] -description = "Butane MVP" +description = "Contracts for the Butane Protocol" dependencies = [ { name = "aiken-lang/stdlib", version = "1.9.0", source = "github" }, { name = "aiken-lang/fuzz", version = "1.0.0", source = "github" }, diff --git a/lib/butane/subvalidators/cdp_script.ak b/lib/butane/subvalidators/cdp_script.ak index 3ad90ef..ef3b699 100644 --- a/lib/butane/subvalidators/cdp_script.ak +++ b/lib/butane/subvalidators/cdp_script.ak @@ -322,6 +322,21 @@ pub fn spend_transitions( actual_repayed_amount == repay_amount, // New CDP is healthy (rational.compare(new_hf, rational.from_int(1)) != Less)?, + // Not adding any additional tokens to the leftover value + { + let + pol, + tok, + qty, + acc, + <- + value.reduce( + leftover_value + |> value.add(mint_script_hash, types.cdp_lock_token_name, -1), + True, + ) + acc && value.quantity_of(cdp_value_no_lock, pol, tok) >= qty + }, } let treasury_share = @@ -415,6 +430,11 @@ pub fn spend_transitions( (rational.compare(hf, rational.from_int(1)) == Less)?, // We must be exceeding the maximum allowed earnings, forcing a leftover (rational.compare(cr, max_liquidation_return_rat) == Greater)?, + // Can't add any additional tokens to the leftover + { + let pol, tok, qty, acc <- value.reduce(leftover_value, True) + acc && value.quantity_of(cdp_value_no_lock, pol, tok) >= qty + }, } utils.get_state_delta( diff --git a/lib/butane/subvalidators/params_script.ak b/lib/butane/subvalidators/params_script.ak index c0e4526..137eb46 100644 --- a/lib/butane/subvalidators/params_script.ak +++ b/lib/butane/subvalidators/params_script.ak @@ -100,10 +100,7 @@ pub fn params_init( // Params token name is in correct format (contains the "p_" prefix followed by the correct synthetic name) (param_token_name == bytearray.concat(types.params_prefix, expected_asset))?, // Synthetic name isn't a reserved name or invalid - (expected_asset != types.cdp_lock_token_name)?, - (expected_asset != types.gov_lock_token_name)?, - (bytearray.take(expected_asset, types.params_prefix_length) != types.params_prefix)?, - (bytearray.take(expected_asset, types.debt_prefix_length) != types.debt_prefix)?, + !utils.is_reserved_asset(expected_asset)?, // Minting one params token (param_minted_qty == 1)?, // Burning one gov lock token diff --git a/lib/butane/subvalidators/staking.ak b/lib/butane/subvalidators/staking.ak index 94a474e..d53d723 100644 --- a/lib/butane/subvalidators/staking.ak +++ b/lib/butane/subvalidators/staking.ak @@ -1,3 +1,4 @@ +use aiken/builtin use aiken/dict use aiken/interval.{Finite, Interval, IntervalBound} use aiken/list @@ -68,63 +69,72 @@ pub fn staking_script( (params_synth == synthetic_asset)?, } } - types.UnstakeSynthetics { verifier } -> { - expect [ - Input { - output: Output { - datum: InlineDatum(raw_staked_datum), - value: staked_value, - .. - }, - output_reference: this_oref, - }, - ] = - list.filter( - inputs, - fn(i: Input) { i.output.address.payment_credential == state_cred }, - ) - expect types.StakedSynthetics { owner, synthetic_asset, start_time } = - utils.to_monodatum(raw_staked_datum) + types.UnstakeSynthetics { verifiers } -> { let end_time = valid_from - expect - utils.authorization_check( - fcredential: owner, - this_oref: this_oref, - verifier: verifier, - extra_signatories: extra_signatories, - inputs: inputs, - withdrawals: withdrawals, - valid_from: valid_from, - valid_to: valid_to, - )? let minted_here = value.tokens(minted, mint_script_hash) |> dict.to_pairs - let staked_amount = - value.quantity_of(staked_value, mint_script_hash, synthetic_asset) expect types.LiveParams{params: types.ActiveParams { staking_interest_rates, .. }}: types.Params = params - let earnings_percent = - utils.calculate_earnings_percent( - staking_interest_rates, - start_time, - end_time, - ) - let earnings_interest = - earnings_percent * staked_amount / types.bp_precision / types.milliseconds_in_year - and { - or { - minted_here == [ - Pair(types.staking_lock_token_name, -1), - Pair(synthetic_asset, earnings_interest), - ], - minted_here == [ - Pair(synthetic_asset, earnings_interest), - Pair(types.staking_lock_token_name, -1), - ], - }, - (params_synth == synthetic_asset)?, - (end_time > start_time)?, + let (expected_lock_tokens, expected_earnings, _) = { + let + Input { + output: Output { datum, value: staked_value, address, .. }, + output_reference: this_oref, + }, + (curr_lock_burn, curr_earnings, curr_verifiers), + <- list.foldl(inputs, (0, 0, verifiers)) + if address.payment_credential != state_cred { + (curr_lock_burn, curr_earnings, curr_verifiers) + } else { + expect InlineDatum(raw_staked_datum) = datum + expect types.StakedSynthetics { owner, synthetic_asset, start_time } = + utils.to_monodatum(raw_staked_datum) + let staked_amount = + value.quantity_of(staked_value, mint_script_hash, synthetic_asset) + let earnings_percent = + utils.calculate_earnings_percent( + staking_interest_rates, + start_time, + end_time, + ) + let earnings_interest = + earnings_percent * staked_amount / types.bp_precision / types.milliseconds_in_year + + expect and { + // Unstaking is authorized + utils.authorization_check( + fcredential: owner, + this_oref: this_oref, + verifier: builtin.head_list(curr_verifiers), + extra_signatories: extra_signatories, + inputs: inputs, + withdrawals: withdrawals, + valid_from: valid_from, + valid_to: valid_to, + )?, + // Valid unstaking time + (end_time > start_time)?, + // Only using one (and the correct) synth + (params_synth == synthetic_asset)?, + } + + ( + curr_lock_burn + 1, + curr_earnings + earnings_interest, + builtin.tail_list(curr_verifiers), + ) + } + } + or { + minted_here == [ + Pair(types.staking_lock_token_name, -expected_lock_tokens), + Pair(params_synth, expected_earnings), + ], + minted_here == [ + Pair(params_synth, expected_earnings), + Pair(types.staking_lock_token_name, -expected_lock_tokens), + ], } } } diff --git a/lib/butane/tests/create_cdp.ak b/lib/butane/tests/create_cdp.ak deleted file mode 100644 index 0174d20..0000000 --- a/lib/butane/tests/create_cdp.ak +++ /dev/null @@ -1,297 +0,0 @@ -use aiken/dict -use aiken/fuzz -use aiken/list -use aiken/transaction.{InlineDatum, Output} -use aiken/transaction/credential.{Address, Inline, ScriptCredential} -use aiken/transaction/value -use butane/subvalidators/cdp_script.{create_transitions} -use butane/tests/fuzzers -use butane/tests/utils.{ - change_output, fake_mint_hash, fake_state_hash, usd_params, usd_price, -} -use butane/types - -test create_0() { - create_transitions( - fake_mint_hash, - fake_state_hash, - 0, - 0, - [], - [], - [], - [change_output()], - dict.new(), - 0, - value.zero(), - 0, - ) == (types.StateDelta(dict.new(), 0, value.zero(), 0), change_output()) -} - -test create_1() { - let cdp_datum = - types.CDP { - owner: types.AuthorizeWithConstraint( - types.MustWithdrawFrom(Inline(ScriptCredential(fake_state_hash))), - ), - synthetic_asset: "USD", - synthetic_amount: 10, - start_time: 1000, - } - let cdp_datum: Data = cdp_datum - let cdp_output = - Output { - value: value.add(value.from_lovelace(10000000), fake_state_hash, "", 1), - address: Address(ScriptCredential(fake_state_hash), None), - datum: InlineDatum(cdp_datum), - reference_script: None, - } - create_transitions( - fake_mint_hash, - fake_state_hash, - 1000, - 3000, - [usd_params()], - [usd_price()], - [1], - [cdp_output, change_output()], - dict.new(), - 0, - value.zero(), - 0, - ) == ( - types.StateDelta(dict.insert(dict.new(), #"555344", 10), 0, value.zero(), 1), - change_output(), - ) -} - -fn push_to_tail(list: List, element: a) -> List { - when list is { - [] -> - [element] - [head, ..tail] -> - [head, ..push_to_tail(tail, element)] - } -} - -test create_composition_simple() { - let cdp_datum = - types.CDP { - owner: types.AuthorizeWithConstraint( - types.MustWithdrawFrom(Inline(ScriptCredential(fake_state_hash))), - ), - synthetic_asset: "USD", - synthetic_amount: 10, - start_time: 1000, - } - let cdp_datum: Data = cdp_datum - let cdp_outputs = - [ - Output { - value: value.add(value.from_lovelace(10000000), fake_state_hash, "", 1), - address: Address(ScriptCredential(fake_state_hash), None), - datum: InlineDatum(cdp_datum), - reference_script: None, - }, - Output { - value: value.add(value.from_lovelace(10000000), fake_state_hash, "", 1), - address: Address(ScriptCredential(fake_state_hash), None), - datum: InlineDatum(cdp_datum), - reference_script: None, - }, - ] - create_transitions( - fake_mint_hash, - fake_state_hash, - 1000, - 3000, - [usd_params()], - [usd_price()], - [list.length(cdp_outputs)], - push_to_tail(cdp_outputs, change_output()), - dict.new(), - 0, - value.zero(), - 0, - ).1st == { - let sds = { - let cdp_output <- list.map(cdp_outputs) - create_transitions( - fake_mint_hash, - fake_state_hash, - 1000, - 3000, - [usd_params()], - [usd_price()], - [1], - [cdp_output, change_output()], - dict.new(), - 0, - value.zero(), - 0, - ).1st - } - let - types.StateDelta { - mint: x_mint, - btn_delta: x_delta, - fee: x_fee, - lock_mints: x_lock, - }, - types.StateDelta { - mint: y_mint, - btn_delta: y_delta, - fee: y_fee, - lock_mints: y_lock, - }, - <- - list.reduce( - sds, - types.StateDelta { - mint: dict.new(), - btn_delta: 0, - fee: value.zero(), - lock_mints: 0, - }, - ) - types.StateDelta { - mint: dict.union_with( - x_mint, - y_mint, - fn(_, q0, q1) { - let q = q0 + q1 - if q == 0 { - None - } else { - Some(q) - } - }, - ), - btn_delta: x_delta + y_delta, - fee: value.merge(x_fee, y_fee), - lock_mints: x_lock + y_lock, - } - } -} - -fn create_composition_fuzzes() { - let fake_mint_hash <- fuzz.and_then(fuzzers.script_hash()) - let fake_state_hash <- fuzz.and_then(fuzzers.script_hash()) - let cdp_outputs <- - fuzz.map( - fuzz.list( - fuzzers.overcollateralised_cdp( - fake_mint_hash, - fake_state_hash, - usd_params(), - usd_price(), - ), - ), - ) - (fake_mint_hash, fake_state_hash, cdp_outputs) -} - -test create_composition(stuff via create_composition_fuzzes()) { - let (fake_mint_hash, fake_state_hash, cdp_outputs) = stuff - create_transitions( - fake_mint_hash, - fake_state_hash, - 1000, - 3000, - [usd_params()], - [usd_price()], - [list.length(cdp_outputs)], - push_to_tail(cdp_outputs, change_output()), - dict.new(), - 0, - value.zero(), - 0, - ).1st == { - let sds = { - let cdp_output <- list.map(cdp_outputs) - create_transitions( - fake_mint_hash, - fake_state_hash, - 1000, - 3000, - [usd_params()], - [usd_price()], - [1], - [cdp_output, change_output()], - dict.new(), - 0, - value.zero(), - 0, - ).1st - } - let - types.StateDelta { - mint: x_mint, - btn_delta: x_delta, - fee: x_fee, - lock_mints: x_lock, - }, - types.StateDelta { - mint: y_mint, - btn_delta: y_delta, - fee: y_fee, - lock_mints: y_lock, - }, - <- - list.reduce( - sds, - types.StateDelta { - mint: dict.new(), - btn_delta: 0, - fee: value.zero(), - lock_mints: 0, - }, - ) - types.StateDelta { - mint: dict.union_with( - x_mint, - y_mint, - fn(_, q0, q1) { - let q = q0 + q1 - if q == 0 { - None - } else { - Some(q) - } - }, - ), - btn_delta: x_delta + y_delta, - fee: value.merge(x_fee, y_fee), - lock_mints: x_lock + y_lock, - } - } -} - -test undercollateralised_fails( - cdp_outputs via fuzz.list_at_least( - fuzzers.undercollateralised_cdp( - fake_mint_hash, - fake_state_hash, - usd_params(), - usd_price(), - ), - 1, - ), -) fail { - let (a, _b) = - create_transitions( - fake_mint_hash, - fake_state_hash, - 1000, - 3000, - [usd_params()], - [usd_price()], - [list.length(cdp_outputs)], - push_to_tail(cdp_outputs, change_output()), - dict.new(), - 0, - value.zero(), - 0, - ) - a.lock_mints >= 0 -} diff --git a/lib/butane/tests/fuzzers.ak b/lib/butane/tests/fuzzers.ak deleted file mode 100644 index 814585f..0000000 --- a/lib/butane/tests/fuzzers.ak +++ /dev/null @@ -1,87 +0,0 @@ -use aiken/fuzz -use aiken/list -use aiken/transaction.{InlineDatum, Output} -use aiken/transaction/credential.{Address, Inline, ScriptCredential} -use aiken/transaction/value.{Value} -use butane/types - -pub fn script_hash() -> Fuzzer { - fuzz.bytearray() -} - -pub fn collateral( - params: types.ParamsData, - prices: types.PriceFeed, -) -> Fuzzer<(Value, Int)> { - expect types.LiveParams{params: types.ActiveParams { - collateral_assets, - weights, - denominator: param_denom, - .. - }} = params.params - let types.PriceFeed { collateral_prices, denominator: price_denom, .. } = - prices - let index <- - fuzz.and_then(fuzz.int_between(0, list.length(collateral_assets) - 1)) - expect Some(collateral) = collateral_assets |> list.at(index) - expect Some(price) = collateral_prices |> list.at(index) - expect Some(weight) = weights |> list.at(index) - let c <- fuzz.and_then(fuzz.int_between(1000000, 100000000)) - let bc = price * c * param_denom / weight / price_denom - fuzz.constant( - (value.from_asset(collateral.policy_id, collateral.asset_name, c), bc), - ) -} - -pub fn overcollateralised_cdp( - fake_mint_hash: ByteArray, - fake_state_hash: ByteArray, - params: types.ParamsData, - prices: types.PriceFeed, -) -> Fuzzer { - let cr <- fuzz.and_then(fuzz.int_between(11000, 14000)) - do_cdp(fake_mint_hash, fake_state_hash, params, prices, cr) -} - -pub fn undercollateralised_cdp( - fake_mint_hash: ByteArray, - fake_state_hash: ByteArray, - params: types.ParamsData, - prices: types.PriceFeed, -) -> Fuzzer { - let cr <- fuzz.and_then(fuzz.int_between(6000, 9000)) - do_cdp(fake_mint_hash, fake_state_hash, params, prices, cr) -} - -pub fn do_cdp( - fake_mint_hash: ByteArray, - fake_state_hash: ByteArray, - params: types.ParamsData, - prices: types.PriceFeed, - cr: Int, -) { - let (val, mint) <- fuzz.and_then(collateral(params, prices)) - let owner_hash <- fuzz.and_then(script_hash()) - let cdp_datum = - types.CDP { - owner: types.AuthorizeWithConstraint( - types.MustWithdrawFrom(Inline(ScriptCredential(owner_hash))), - ), - synthetic_asset: "USD", - synthetic_amount: mint * 10000 / cr, - start_time: 1000, - } - fuzz.constant( - Output { - value: value.add( - value.merge(value.from_lovelace(0), val), - fake_mint_hash, - "", - 1, - ), - address: Address(ScriptCredential(fake_state_hash), None), - datum: InlineDatum(cdp_datum), - reference_script: None, - }, - ) -} diff --git a/lib/butane/tests/repay_cdp.ak b/lib/butane/tests/repay_cdp.ak deleted file mode 100644 index acf633d..0000000 --- a/lib/butane/tests/repay_cdp.ak +++ /dev/null @@ -1,101 +0,0 @@ -use aiken/dict -use aiken/transaction.{ - InlineDatum, Input, Output, OutputReference, TransactionId, -} -use aiken/transaction/credential.{Address, ScriptCredential} -use aiken/transaction/value -use butane/subvalidators/cdp_script.{spend_transitions} -use butane/tests/utils.{ - fake_asset_class, fake_leftovers_hash, fake_mint_hash, fake_state_hash, -} -use butane/types - -test spend_0() { - spend_transitions( - [], - [], - fake_state_hash, - fake_mint_hash, - fake_leftovers_hash, - 10_000, - 1000, - [], - [], - fake_asset_class(), - fn() { True }, - [], - [], - [], - dict.new(), - 0, - value.zero(), - 0, - fn(_a, _b, _c, _d, _e) { 0 }, - ) == 0 -} - -test spend_1() { - let cdp_datum = - types.CDP { - owner: types.AuthorizeWithPubKey("", ""), - synthetic_asset: "USD", - synthetic_amount: 20000000, - start_time: 1000, - } - let cdp_datum: Data = cdp_datum - let cdp_input = - Input { - output: Output { - value: value.add(value.from_lovelace(10000000), fake_state_hash, "", 1), - address: Address(ScriptCredential(fake_state_hash), None), - datum: InlineDatum(cdp_datum), - reference_script: None, - }, - output_reference: OutputReference { - transaction_id: TransactionId(""), - output_index: 0, - }, - } - - let - _remaining_outputs, - x_mint, - x_btn_delta, - x_fee, - x_lock_mints, - <- - spend_transitions( - [cdp_input], - [], - fake_state_hash, - fake_mint_hash, - fake_leftovers_hash, - 1000, - 100, - [""], - [], - fake_asset_class(), - fn() { True }, - [utils.usd_params()], - [utils.usd_price()], - [ - types.SpendAction { - spend_type: types.RepayCDP( - types.AuthorizingDirectly(types.AuthorizedWithExtraSigs), - ), - params_idx: 0, - fee_type: types.FeeInSynthetic, - }, - ], - dict.new(), - 0, - value.zero(), - 0, - ) - and { - x_mint == dict.insert(dict.new(), #"555344", -20000000), - x_btn_delta == 0, - x_fee == value.zero(), - x_lock_mints == -1, - } -} diff --git a/lib/butane/tests/utils.ak b/lib/butane/tests/utils.ak deleted file mode 100644 index 0f31001..0000000 --- a/lib/butane/tests/utils.ak +++ /dev/null @@ -1,67 +0,0 @@ -use aiken/interval.{Finite, Interval, IntervalBound} -use aiken/transaction.{NoDatum, Output} -use aiken/transaction/credential.{Address, ScriptCredential} -use aiken/transaction/value -use butane/types - -pub const fake_mint_hash = - #"4fe5fcedb7f1061f9e9c25d1811cba7a5b452be6a3669a8b81e1ac0a44aa3f9e" - -pub const fake_state_hash = - #"4fe5fcedb7f1061f9e9c25d1811cba7a5b452be6a3669a8b81e1ac0a44aa3f9e" - -pub const fake_leftovers_hash = - #"4fe5fcedb7f1061f9e9c25d1811cba7a5b452be6a3669a8b81e1ac0a44aa3f9e" - -pub fn fake_asset_class() { - types.AssetClass { policy_id: "", asset_name: "" } -} - -pub fn change_output() { - Output { - value: value.zero(), - address: Address(ScriptCredential(""), None), - datum: NoDatum, - reference_script: None, - } -} - -pub fn usd_params() { - types.ParamsData { - params: types.LiveParams { - params: types.ActiveParams { - collateral_assets: [types.AssetClass("", "")], - weights: [110], - denominator: 100, - minimum_outstanding_synthetic: 0, - interest_rates: [(200, 200)], - max_proportions: [10000], - max_liquidation_return: 10000, - treasury_liquidation_share: 1, - redemption_share: 0, - fee_token_discount: 0, - staking_interest_rates: [(200, 200)], - }, - }, - synthetic: "USD", - } -} - -pub fn usd_price() { - types.PriceFeed { - collateral_prices: [100], - synthetic: "USD", - denominator: 1, - validity: non_interval(), - } -} - -pub fn non_interval() { - Interval { - lower_bound: IntervalBound { bound_type: Finite(2000), is_inclusive: False }, - upper_bound: IntervalBound { - bound_type: Finite(10000), - is_inclusive: False, - }, - } -} diff --git a/lib/butane/types.ak b/lib/butane/types.ak index 3b78207..2b7f0cb 100644 --- a/lib/butane/types.ak +++ b/lib/butane/types.ak @@ -466,7 +466,7 @@ pub type AuxilliaryRedeemer { pub type StakingRedeemer { StakeSynthetics { staked_amount: Int } - UnstakeSynthetics { verifier: CDPCredentialVerifier } + UnstakeSynthetics { verifiers: List } } // Price feeds diff --git a/lib/butane/utils.ak b/lib/butane/utils.ak index f64642e..58c9d7a 100644 --- a/lib/butane/utils.ak +++ b/lib/butane/utils.ak @@ -313,6 +313,16 @@ pub fn is_minimal_denom(nums: List, denom: Int) -> Bool { list.foldl(nums, denom, fn(n, acc) { math.gcd(math.gcd(n, denom), acc) }) == 1 } +pub fn is_reserved_asset(asset: AssetName) -> Bool { + or { + (asset == types.cdp_lock_token_name)?, + (asset == types.gov_lock_token_name)?, + (asset == types.staking_lock_token_name)?, + (bytearray.take(asset, types.params_prefix_length) == types.params_prefix)?, + (bytearray.take(asset, types.debt_prefix_length) == types.debt_prefix)?, + } +} + pub fn authorization_check( fcredential: types.CDPCredential, this_oref: OutputReference, diff --git a/validators/pointers.ak b/validators/pointers.ak index 0506d29..06a36c8 100644 --- a/validators/pointers.ak +++ b/validators/pointers.ak @@ -15,7 +15,7 @@ validator(upgradeable_script_hash: Referenced) { validator( upgradeable_script_hash: Referenced, - /// Arbitrary number for mining to ensure that the minting policy is low (vanity reasons) + /// Arbitrary number for mining to allow for mining of a low minting policy _salt: Int, ) { fn mint(_redeemer: Data, ctx: ScriptContext) -> Bool {