diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..8913685 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,20 @@ +name: Tests + +on: + push: + branches: ["main"] + pull_request: + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - uses: aiken-lang/setup-aiken@v0.1.0 + with: + version: v1 + + - run: aiken fmt --check + - run: aiken check -D + - run: aiken build diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ff7811b --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +# Aiken compilation artifacts +artifacts/ +# Aiken's project working directory +build/ +# Aiken's default documentation export +docs/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..fb0a00a --- /dev/null +++ b/README.md @@ -0,0 +1,57 @@ +# Bullet + +Write validators in the `validators` folder, and supporting functions in the +`lib` folder using `.ak` as a file extension. + +For example, as `validators/always_true.ak` + +```gleam +validator { + fn spend(_datum: Data, _redeemer: Data, _context: Data) -> Bool { + True + } +} +``` + +## Building + +```sh +aiken build +``` + +## Testing + +You can write tests in any module using the `test` keyword. For example: + +```gleam +test foo() { + 1 + 1 == 2 +} +``` + +To run all tests, simply do: + +```sh +aiken check +``` + +To run only tests matching the string `foo`, do: + +```sh +aiken check -m foo +``` + +## Documentation + +If you're writing a library, you might want to generate an HTML documentation +for it. + +Use: + +```sh +aiken docs +``` + +## Resources + +Find more on the [Aiken's user manual](https://aiken-lang.org). diff --git a/aiken.lock b/aiken.lock new file mode 100644 index 0000000..0a72eb3 --- /dev/null +++ b/aiken.lock @@ -0,0 +1,15 @@ +# This file was generated by Aiken +# You typically do not need to edit this file + +[[requirements]] +name = "aiken-lang/stdlib" +version = "1.7.0" +source = "github" + +[[packages]] +name = "aiken-lang/stdlib" +version = "1.7.0" +requirements = [] +source = "github" + +[etags] diff --git a/aiken.toml b/aiken.toml new file mode 100644 index 0000000..8abb7ed --- /dev/null +++ b/aiken.toml @@ -0,0 +1,14 @@ +name = "aiken-lang/composable-account" +version = "0.0.0" +license = "Apache-2.0" +description = "Aiken contracts for project 'aiken-lang/composable-account'" + +[repository] +user = "aiken-lang" +project = "composable-account" +platform = "github" + +[[dependencies]] +name = "aiken-lang/stdlib" +version = "1.7.0" +source = "github" diff --git a/lib/composable-account/intentions.ak b/lib/composable-account/intentions.ak new file mode 100644 index 0000000..e69de29 diff --git a/lib/composable-account/utils.ak b/lib/composable-account/utils.ak new file mode 100644 index 0000000..aa9db35 --- /dev/null +++ b/lib/composable-account/utils.ak @@ -0,0 +1,38 @@ +use aiken/builtin +use aiken/dict.{Dict} +use aiken/hash.{Blake2b_224, Hash} +use aiken/list +use aiken/transaction/credential.{VerificationKey} + +pub fn list_at(list: List, index: Int) -> a { + if index == 0 { + builtin.head_list(list) + } else { + builtin.tail_list(list) |> list_at(index - 1) + } +} + +pub fn dict_get(dict: Dict, key: a) -> b { + dict |> dict.to_list |> do_dict_get(key) +} + +fn do_dict_get(list: List<(a, b)>, key: a) -> b { + expect [head, ..tail] = list + + if builtin.fst_pair(head) == key { + builtin.snd_pair(head) + } else { + do_dict_get(tail, key) + } +} + +pub fn validate_keys_present( + owners: List>, + signers: List>, +) -> Bool { + when owners is { + [] -> True + [owner, ..rest_owners] -> + list.has(signers, owner) && validate_keys_present(rest_owners, signers) + } +} diff --git a/validators/account.ak b/validators/account.ak new file mode 100644 index 0000000..f63053c --- /dev/null +++ b/validators/account.ak @@ -0,0 +1,251 @@ +use aiken/builtin +use aiken/dict.{Dict} +use aiken/hash.{Blake2b_224, Hash} +use aiken/interval.{Finite, Interval, IntervalBound} +use aiken/list +use aiken/time.{PosixTime} +use aiken/transaction.{ + DatumHash, InlineDatum, Input, Mint, Output, OutputReference, Publish, + Redeemer, ScriptContext, ScriptPurpose, Spend, Transaction, WithdrawFrom, +} +use aiken/transaction/certificate.{ + CredentialDelegation, CredentialDeregistration, +} +use aiken/transaction/credential.{ + Address, Credential, Inline, PoolId, ScriptCredential, VerificationKey, +} +use aiken/transaction/value.{ + AssetName, PolicyId, from_minted_value, quantity_of, tokens, +} +use composable_account/utils.{list_at, validate_keys_present} + +// For now only support Pub key hash +type Owner { + owner_list: List>, +} + +type OutputCondition { + HasAddress(Address) + HasToken(PolicyId, AssetName, Int) + HasDatum(Data) + HasPC(Credential) + HasField { field_data: Data, field_path: List } + HasHash(ByteArray) +} + +type TxCondition { + Mints(PolicyId, AssetName, Int) + IsAfter(PosixTime) + IsBefore(PosixTime) + /// Please note the `outer list` in InputsCondition could all be satisfied by a single input. + /// The checks are per all inputs not a unique input + /// The `inner list` acts on a single input ensuring the input meets all conditions via && + InputsCondition(List>) + ReferenceInputsCondition(List>) + OutputsCondition(List>) + StakesTo(PoolId) +} + +type IntentionCondition { + Is(TxCondition) + And(List) + Or(List) + Not(TxCondition) +} + +type State { + ControlState { owner: Owner } + NonceState +} + +type Intention { + condition: IntentionCondition, + value_leaving: List<(PolicyId, AssetName, Int)>, + nonce: OutputReference, +} + +type DataEnvelope { + prefix: ByteArray, + intention: Intention, + postfix: ByteArray, + signatures: List, +} + +type ValidateRedeemer { + SpendFromAccount(DataEnvelope) + UpdateConfig(Int, Int) + Destroy(Int) + Initialize(Int) +} + +type StakeRedeemer { + InitAddress(ByteArray) + StakeTo + Withdraw +} + +validator(init_input: OutputReference) { + fn stake_validator(_rdr, ctx: ScriptContext) -> Bool { + todo + } +} + +validator { + fn spend(_dat, input_index: Int, ctx: ScriptContext) -> Bool { + expect ScriptContext { transaction: tx, purpose: Spend(own_ref) } = ctx + + let Transaction { inputs, withdrawals, .. } = tx + + let Input { output_reference, output } = inputs |> list_at(input_index) + + let withdraw_cred = Inline(output.address.payment_credential) + + // Using index for faster look up and this check is safe as long as the + // withdraw credential is aware of this input + output_reference == own_ref && dict.has_key(withdrawals, withdraw_cred) + } + + fn validate(rdr: Data, ctx: ScriptContext) -> Bool { + let ScriptContext { transaction: tx, purpose } = ctx + + let Transaction { + inputs, + reference_inputs, + outputs, + mint, + redeemers, + extra_signatories, + .. + } = tx + + when purpose is { + WithdrawFrom(Inline(ScriptCredential(own_policy_id))) -> { + expect rdr: ValidateRedeemer = rdr + + when rdr is { + SpendFromAccount(data_envelope) -> todo + UpdateConfig(input_index, output_index) -> { + let Output { address, value, datum, .. } = + list_at(inputs, input_index).output + + expect [(asset_name, 1)] = + value |> value.tokens(own_policy_id) |> dict.to_list + + expect InlineDatum(datum) = datum + + expect ControlState { owner }: State = datum + + let Output { + address: out_address, + value: out_value, + datum: out_datum, + .. + } = list_at(outputs, output_index) + + expect InlineDatum(out_datum) = out_datum + + expect ControlState { .. }: State = out_datum + + expect [(out_asset_name, 1)] = + out_value |> value.tokens(own_policy_id) |> dict.to_list + + and { + validate_keys_present(owner.owner_list, extra_signatories), + asset_name == out_asset_name, + address == out_address, + } + } + Destroy(input_index) -> { + let Output { value, datum, .. } = + list_at(inputs, input_index).output + + expect [(asset_name, 1)] = + value |> value.tokens(own_policy_id) |> dict.to_list + + let burned_quantity = + mint + |> value.from_minted_value + |> quantity_of(own_policy_id, asset_name) + + expect InlineDatum(datum) = datum + + expect ControlState { owner }: State = datum + + and { + burned_quantity == -1, + validate_keys_present(owner.owner_list, extra_signatories), + } + } + + _ -> False + } + } + + Mint(own_policy_id) -> { + expect [(token_name, amount)] = + mint + |> value.from_minted_value + |> value.tokens(own_policy_id) + |> dict.to_list + + if amount == 1 { + expect Initialize(output_index): ValidateRedeemer = rdr + + let control_output = list_at(outputs, output_index) + + validate_minted_nft( + control_output, + own_policy_id, + token_name, + redeemers, + ) + } else if amount == -1 { + True + } else { + False + } + } + + _ -> False + } + } +} + +/// Used to validate initializing the account. +/// This depends on the stake_validator to guarantee address to NFT uniqueness +fn validate_minted_nft( + output: Output, + own_policy_id: PolicyId, + token_name: AssetName, + redeemers: Dict, +) -> Bool { + expect Output { + address: Address { + payment_credential: ScriptCredential(own_pc), + stake_credential, + }, + value, + datum, + .. + } = output + + let asset_quantity = quantity_of(value, own_policy_id, token_name) + + expect Some(withdraw_cred) = stake_credential + + let withdraw_purpose = WithdrawFrom(withdraw_cred) + + expect Some(stake_redeemer) = dict.get(redeemers, withdraw_purpose) + + let expected_redeemer: Data = InitAddress(token_name) + + expect InlineDatum(datum) = datum + + expect ControlState { .. }: State = datum + + and { + own_pc == own_policy_id, + stake_redeemer == expected_redeemer, + asset_quantity == 1, + } +}