From 1064140f340ae75682973edea65846be338f0eb3 Mon Sep 17 00:00:00 2001 From: Sixela33 Date: Sun, 22 Mar 2026 18:08:06 -0300 Subject: [PATCH 1/4] chore: uploading base contract --- .../workflows/continuous-integration.yml | 18 +++++ lazer/cardano/5minBets/.gitignore | 6 ++ lazer/cardano/5minBets/README.md | 65 +++++++++++++++ lazer/cardano/5minBets/aiken.lock | 15 ++++ lazer/cardano/5minBets/aiken.toml | 18 +++++ lazer/cardano/5minBets/plutus.json | 81 +++++++++++++++++++ .../5minBets/validators/hello_world.ak | 30 +++++++ 7 files changed, 233 insertions(+) create mode 100644 lazer/cardano/5minBets/.github/workflows/continuous-integration.yml create mode 100644 lazer/cardano/5minBets/.gitignore create mode 100644 lazer/cardano/5minBets/README.md create mode 100644 lazer/cardano/5minBets/aiken.lock create mode 100644 lazer/cardano/5minBets/aiken.toml create mode 100644 lazer/cardano/5minBets/plutus.json create mode 100644 lazer/cardano/5minBets/validators/hello_world.ak diff --git a/lazer/cardano/5minBets/.github/workflows/continuous-integration.yml b/lazer/cardano/5minBets/.github/workflows/continuous-integration.yml new file mode 100644 index 00000000..8af80c86 --- /dev/null +++ b/lazer/cardano/5minBets/.github/workflows/continuous-integration.yml @@ -0,0 +1,18 @@ +name: Continuous Integration + +on: + push: + branches: ["main"] + pull_request: + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: aiken-lang/setup-aiken@v1 + with: + version: v1.1.21 + - run: aiken fmt --check + - run: aiken check -D + - run: aiken build diff --git a/lazer/cardano/5minBets/.gitignore b/lazer/cardano/5minBets/.gitignore new file mode 100644 index 00000000..ff7811b1 --- /dev/null +++ b/lazer/cardano/5minBets/.gitignore @@ -0,0 +1,6 @@ +# Aiken compilation artifacts +artifacts/ +# Aiken's project working directory +build/ +# Aiken's default documentation export +docs/ diff --git a/lazer/cardano/5minBets/README.md b/lazer/cardano/5minBets/README.md new file mode 100644 index 00000000..a66983e3 --- /dev/null +++ b/lazer/cardano/5minBets/README.md @@ -0,0 +1,65 @@ +# aiken + +Write validators in the `validators` folder, and supporting functions in the `lib` folder using `.ak` as a file extension. + +```aiken +validator my_first_validator { + spend(_datum: Option, _redeemer: Data, _output_reference: Data, _context: Data) { + True + } +} +``` + +## Building + +```sh +aiken build +``` + +## Configuring + +**aiken.toml** +```toml +[config.default] +network_id = 41 +``` + +Or, alternatively, write conditional environment modules under `env`. + +## Testing + +You can write tests in any module using the `test` keyword. For example: + +```aiken +use config + +test foo() { + config.network_id + 1 == 42 +} +``` + +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/lazer/cardano/5minBets/aiken.lock b/lazer/cardano/5minBets/aiken.lock new file mode 100644 index 00000000..1a4fdb1c --- /dev/null +++ b/lazer/cardano/5minBets/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 = "v3.0.0" +source = "github" + +[[packages]] +name = "aiken-lang/stdlib" +version = "v3.0.0" +requirements = [] +source = "github" + +[etags] diff --git a/lazer/cardano/5minBets/aiken.toml b/lazer/cardano/5minBets/aiken.toml new file mode 100644 index 00000000..35840d9c --- /dev/null +++ b/lazer/cardano/5minBets/aiken.toml @@ -0,0 +1,18 @@ +name = "alex/aiken" +version = "0.0.0" +compiler = "v1.1.21" +plutus = "v3" +license = "Apache-2.0" +description = "Aiken contracts for project 'alex/aiken'" + +[repository] +user = "alex" +project = "aiken" +platform = "github" + +[[dependencies]] +name = "aiken-lang/stdlib" +version = "v3.0.0" +source = "github" + +[config] diff --git a/lazer/cardano/5minBets/plutus.json b/lazer/cardano/5minBets/plutus.json new file mode 100644 index 00000000..f1b697f8 --- /dev/null +++ b/lazer/cardano/5minBets/plutus.json @@ -0,0 +1,81 @@ +{ + "preamble": { + "title": "alex/aiken", + "description": "Aiken contracts for project 'alex/aiken'", + "version": "0.0.0", + "plutusVersion": "v3", + "compiler": { + "name": "Aiken", + "version": "v1.1.21+42babe5" + }, + "license": "Apache-2.0" + }, + "validators": [ + { + "title": "hello_world.hello_world.spend", + "datum": { + "title": "datum", + "schema": { + "$ref": "#/definitions/hello_world~1Datum" + } + }, + "redeemer": { + "title": "redeemer", + "schema": { + "$ref": "#/definitions/hello_world~1Redeemer" + } + }, + "compiledCode": "59010701010029800aba2aba1aab9faab9eaab9dab9a48888896600264646644b30013370e900118031baa00189919912cc004cdc3a400060126ea801626464b3001300f0028acc004cdc3a400060166ea800e2b30013371e6eb8c038c030dd5003a450d48656c6c6f2c20576f726c642100899199119801001000912cc00400629422b30013371e6eb8c04400400e2946266004004602400280690101bac300f30103010301030103010301030103010300d3754601e0146eb8c038c030dd5180718061baa0038a5040291640291640346eb8c034004c028dd5002c5900818050009805180580098039baa0018a504014600e002600e6010002600e00260066ea801e29344d95900101", + "hash": "d36d6f01490e361d6944b224feecd3875751a05eebae444a30d4125c" + }, + { + "title": "hello_world.hello_world.else", + "redeemer": { + "schema": {} + }, + "compiledCode": "59010701010029800aba2aba1aab9faab9eaab9dab9a48888896600264646644b30013370e900118031baa00189919912cc004cdc3a400060126ea801626464b3001300f0028acc004cdc3a400060166ea800e2b30013371e6eb8c038c030dd5003a450d48656c6c6f2c20576f726c642100899199119801001000912cc00400629422b30013371e6eb8c04400400e2946266004004602400280690101bac300f30103010301030103010301030103010300d3754601e0146eb8c038c030dd5180718061baa0038a5040291640291640346eb8c034004c028dd5002c5900818050009805180580098039baa0018a504014600e002600e6010002600e00260066ea801e29344d95900101", + "hash": "d36d6f01490e361d6944b224feecd3875751a05eebae444a30d4125c" + } + ], + "definitions": { + "ByteArray": { + "dataType": "bytes" + }, + "aiken/crypto/VerificationKeyHash": { + "title": "VerificationKeyHash", + "dataType": "bytes" + }, + "hello_world/Datum": { + "title": "Datum", + "anyOf": [ + { + "title": "Datum", + "dataType": "constructor", + "index": 0, + "fields": [ + { + "title": "owner", + "$ref": "#/definitions/aiken~1crypto~1VerificationKeyHash" + } + ] + } + ] + }, + "hello_world/Redeemer": { + "title": "Redeemer", + "anyOf": [ + { + "title": "Redeemer", + "dataType": "constructor", + "index": 0, + "fields": [ + { + "title": "msg", + "$ref": "#/definitions/ByteArray" + } + ] + } + ] + } + } +} \ No newline at end of file diff --git a/lazer/cardano/5minBets/validators/hello_world.ak b/lazer/cardano/5minBets/validators/hello_world.ak new file mode 100644 index 00000000..54372d1e --- /dev/null +++ b/lazer/cardano/5minBets/validators/hello_world.ak @@ -0,0 +1,30 @@ +use aiken/collection/list +use aiken/crypto.{VerificationKeyHash} +use cardano/script_context.{ScriptContext} +use cardano/transaction.{OutputReference, Transaction} + +pub type Datum { + owner: VerificationKeyHash, +} + +pub type Redeemer { + msg: ByteArray, +} + +validator hello_world { + spend( + datum: Option, + redeemer: Redeemer, + _own_ref: OutputReference, + self: Transaction, + ) { + expect Some(Datum { owner }) = datum + let must_say_hello = redeemer.msg == "Hello, World!" + let must_be_signed = list.has(self.extra_signatories, owner) + must_say_hello && must_be_signed + } + + else(_ctx: ScriptContext) { + False + } +} \ No newline at end of file From efc8800c10020eeaecf02c4541c711fcf0a4eab0 Mon Sep 17 00:00:00 2001 From: Sixela33 Date: Sun, 22 Mar 2026 18:25:03 -0300 Subject: [PATCH 2/4] chore: completing readme --- lazer/cardano/5minBets/README.md | 102 +++++++++++++++++++++---------- 1 file changed, 69 insertions(+), 33 deletions(-) diff --git a/lazer/cardano/5minBets/README.md b/lazer/cardano/5minBets/README.md index a66983e3..e1d65dcc 100644 --- a/lazer/cardano/5minBets/README.md +++ b/lazer/cardano/5minBets/README.md @@ -1,65 +1,101 @@ -# aiken +# 5minBets -Write validators in the `validators` folder, and supporting functions in the `lib` folder using `.ak` as a file extension. +`5minBets` is a Cardano-native price prediction market concept written in [Aiken](https://aiken-lang.org). Users bet `UP` or `DOWN` on the `XAU/USD` (gold) price over fixed 5-minute rounds, using [Pyth Network](https://pyth.network) price data to determine the outcome. -```aiken -validator my_first_validator { - spend(_datum: Option, _redeemer: Data, _output_reference: Data, _context: Data) { - True - } -} -``` +## Status -## Building +This repository is currently an early prototype. The product idea and intended round mechanics are described below, but the on-chain implementation in this repo is still minimal and does not yet include the full betting protocol. -```sh -aiken build -``` +## Concept + +Each market round follows a simple lifecycle: -## Configuring +1. A relayer opens a round and records the current `XAU/USD` price from Pyth. +2. Users place bets on whether the price will go `UP` or `DOWN` during the next 5 minutes. +3. After the 5-minute window closes, the relayer submits the closing price from Pyth. +4. The contract determines the winning side and enables winners to claim their proportional share of the pool. +5. A 2% protocol fee is deducted before payouts. -**aiken.toml** -```toml -[config.default] -network_id = 41 +## Design assumptions + +The idea is straightforward, but a production-ready version should make these rules explicit: + +- Oracle prices must be verifiable on-chain or otherwise constrained by trusted update logic. +- The relayer must not be able to selectively skip, delay, or manipulate round settlement. +- The market should clearly define what happens in edge cases such as equal open/close prices, no bets on one side, or failed settlement. +- Asset support should be precise. If multiple stablecoins are accepted, the contract must define how they are normalized or separated. + +## Current repository state + +At the moment, this repo contains a basic Aiken validator example rather than the finished betting application: + +```text +.github/ + workflows/ + continuous-integration.yml +validators/ + hello_world.ak +lib/ # empty — planned home for types.ak and utils.ak +env/ # empty +aiken.toml +aiken.lock # pins aiken-lang/stdlib v3.0.0 for reproducible builds +plutus.json +.gitignore +README.md ``` -Or, alternatively, write conditional environment modules under `env`. +That means this README should be read as a project direction and protocol outline, not as a description of a completed implementation. -## Testing +## Planned architecture -You can write tests in any module using the `test` keyword. For example: +The intended project structure for the full version would look something like this: -```aiken -use config +```text +validators/ + round_validator.ak # Core betting logic: bet, settle, claim +lib/ + types.ak # RoundDatum, RoundStatus, action types + utils.ak # Price helpers, pool math, winner checks +``` + +## Building -test foo() { - config.network_id + 1 == 42 -} +Build the current Aiken project with: + +```sh +aiken build ``` -To run all tests, simply do: +This produces compiled Plutus artifacts such as `plutus.json`. + +## Testing + +Run the test suite with: ```sh aiken check ``` -To run only tests matching the string `foo`, do: +Run only tests matching a string: ```sh -aiken check -m foo +aiken check -m hello ``` ## Documentation -If you're writing a library, you might want to generate an HTML documentation for it. - -Use: +Generate HTML documentation with: ```sh aiken docs ``` +## Continuous integration + +A GitHub Actions workflow at `.github/workflows/continuous-integration.yml` runs on every push and pull request. It checks formatting (`aiken fmt --check`), runs tests (`aiken check -D`), and builds the project (`aiken build`). + ## Resources -Find more on the [Aiken's user manual](https://aiken-lang.org). +- [Aiken user manual](https://aiken-lang.org) +- [Pyth Network on Cardano](https://docs.pyth.network/price-feeds/use-real-time-data/cardano) +- [Cardano developer docs](https://developers.cardano.org) \ No newline at end of file From 25349b308e3ae323dc8d935a226e6c9b518d99a9 Mon Sep 17 00:00:00 2001 From: Sixela33 Date: Sun, 22 Mar 2026 18:53:35 -0300 Subject: [PATCH 3/4] chore: improving readme --- lazer/cardano/5minBets/README.md | 82 +++++++++++++++++++++++++++----- 1 file changed, 69 insertions(+), 13 deletions(-) diff --git a/lazer/cardano/5minBets/README.md b/lazer/cardano/5minBets/README.md index e1d65dcc..6b55d9b7 100644 --- a/lazer/cardano/5minBets/README.md +++ b/lazer/cardano/5minBets/README.md @@ -1,6 +1,6 @@ # 5minBets -`5minBets` is a Cardano-native price prediction market concept written in [Aiken](https://aiken-lang.org). Users bet `UP` or `DOWN` on the `XAU/USD` (gold) price over fixed 5-minute rounds, using [Pyth Network](https://pyth.network) price data to determine the outcome. +`5minBets` is a Cardano-native price prediction market written in [Aiken](https://aiken-lang.org). Users bet `UP` or `DOWN` on whether the **gold price in US dollars** (`XAU/USD`) will rise or fall over a fixed 5-minute window, using [Pyth Lazer](https://pyth.network) price data (feed ID `6`) to determine the outcome. ## Status @@ -16,6 +16,50 @@ Each market round follows a simple lifecycle: 4. The contract determines the winning side and enables winners to claim their proportional share of the pool. 5. A 2% protocol fee is deducted before payouts. +### Round lifecycle + +```mermaid +sequenceDiagram + actor Relayer + actor User A + actor User B + participant Contract + participant Pyth + + Relayer->>Pyth: Fetch XAU/USD opening price + Relayer->>Contract: Create round UTxO (open_price, deadline) + + User A->>Contract: PlaceBet(UP, 100 ADA) + User B->>Contract: PlaceBet(DOWN, 50 ADA) + + Note over Contract: 5-minute window closes + + Relayer->>Pyth: Fetch XAU/USD closing price + Relayer->>Contract: Settle(close_price) + Note over Contract: status → Settled(UP) + + User A->>Contract: Claim + Contract-->>User A: ~145.5 ADA (gross 150 − 2% fee) + + Relayer->>Contract: CollectFee (future) +``` + +### Round states + +```mermaid +stateDiagram-v2 + [*] --> Open : Relayer creates round UTxO + + Open --> Open : PlaceBet (before deadline) + Open --> Settled : Settle — close ≠ open, both sides have bets + Open --> Draw : Settle — close == open + Open --> Void : Settle — one side has no bets + + Settled --> Settled : Claim (winner collects, added to claimed list) + Draw --> Draw : Refund (bettor recovers stake) + Void --> Void : Refund (bettor recovers stake) +``` + ## Design assumptions The idea is straightforward, but a production-ready version should make these rules explicit: @@ -27,37 +71,49 @@ The idea is straightforward, but a production-ready version should make these ru ## Current repository state -At the moment, this repo contains a basic Aiken validator example rather than the finished betting application: +This repo contains an initial prototype of the betting protocol alongside the original placeholder validator: ```text .github/ workflows/ continuous-integration.yml validators/ - hello_world.ak -lib/ # empty — planned home for types.ak and utils.ak -env/ # empty + hello_world.ak # original placeholder, kept for reference + round_validator.ak # prototype betting logic: PlaceBet / Settle / Claim / Refund +lib/ + types.ak # RoundDatum, RoundStatus, BetSide, Bet, Action + utils.ak # pool math, winner determination, payout calculation +env/ # empty aiken.toml -aiken.lock # pins aiken-lang/stdlib v3.0.0 for reproducible builds +aiken.lock # pins aiken-lang/stdlib v3.0.0 for reproducible builds plutus.json .gitignore README.md ``` -That means this README should be read as a project direction and protocol outline, not as a description of a completed implementation. +The prototype implements the core round mechanics. See **Design assumptions** for what a production version would need to harden. -## Planned architecture - -The intended project structure for the full version would look something like this: +## Architecture ```text validators/ - round_validator.ak # Core betting logic: bet, settle, claim + round_validator.ak # Core betting logic: PlaceBet, Settle, Claim, Refund lib/ - types.ak # RoundDatum, RoundStatus, action types - utils.ak # Price helpers, pool math, winner checks + types.ak # RoundDatum, RoundStatus, BetSide, Bet, Action + utils.ak # Pool math, winner determination, payout calculation ``` +### Validator actions + +| Action | Who | When | Effect | +|--------|-----|------|--------| +| `PlaceBet` | Any user | Before deadline | Appends bet to datum, locks ADA | +| `Settle` | Relayer | After deadline | Records close price, sets status | +| `Claim` | Winner | After settlement | Pays net payout, marks as claimed | +| `Refund` | Any bettor | Draw or Void | Returns stake, marks as claimed | + +A round UTxO is **created** (not validated) when the relayer sends ADA to the script address with an initial `RoundDatum`. No minting policy is required for the prototype. + ## Building Build the current Aiken project with: From 0aafeb152b618e3ef4db3bc27594862908271325 Mon Sep 17 00:00:00 2001 From: Sixela33 Date: Sun, 22 Mar 2026 19:41:00 -0300 Subject: [PATCH 4/4] chore: protocol v0 --- lazer/cardano/5minBets/README.md | 28 +- lazer/cardano/5minBets/lib/types.ak | 62 ++ lazer/cardano/5minBets/lib/utils.ak | 121 ++++ lazer/cardano/5minBets/plutus.json | 248 ++++++++ .../5minBets/validators/hello_world.ak | 30 - .../5minBets/validators/round_validator.ak | 252 ++++++++ .../validators/round_validator_tests.ak | 568 ++++++++++++++++++ 7 files changed, 1266 insertions(+), 43 deletions(-) create mode 100644 lazer/cardano/5minBets/lib/types.ak create mode 100644 lazer/cardano/5minBets/lib/utils.ak delete mode 100644 lazer/cardano/5minBets/validators/hello_world.ak create mode 100644 lazer/cardano/5minBets/validators/round_validator.ak create mode 100644 lazer/cardano/5minBets/validators/round_validator_tests.ak diff --git a/lazer/cardano/5minBets/README.md b/lazer/cardano/5minBets/README.md index 6b55d9b7..64e93421 100644 --- a/lazer/cardano/5minBets/README.md +++ b/lazer/cardano/5minBets/README.md @@ -1,10 +1,10 @@ # 5minBets -`5minBets` is a Cardano-native price prediction market written in [Aiken](https://aiken-lang.org). Users bet `UP` or `DOWN` on whether the **gold price in US dollars** (`XAU/USD`) will rise or fall over a fixed 5-minute window, using [Pyth Lazer](https://pyth.network) price data (feed ID `6`) to determine the outcome. +`5minBets` is a Cardano-native price prediction market written in [Aiken](https://aiken-lang.org). Users bet `UP` or `DOWN` on whether the **gold price in US dollars** (`XAU/USD`) will rise or fall over a fixed 5-minute window, using [Pyth Lazer](https://pyth.network) price data (feed ID `6` — verify before deploying) to determine the outcome. ## Status -This repository is currently an early prototype. The product idea and intended round mechanics are described below, but the on-chain implementation in this repo is still minimal and does not yet include the full betting protocol. +The core betting protocol is implemented. `PlaceBet`, `Settle`, `Claim`, and `Refund` actions are fully validated on-chain. See **Design assumptions** for what a production deployment would need to harden further. ## Concept @@ -39,7 +39,7 @@ sequenceDiagram Note over Contract: status → Settled(UP) User A->>Contract: Claim - Contract-->>User A: ~145.5 ADA (gross 150 − 2% fee) + Contract-->>User A: ~147 ADA (gross 150 − 3 ADA fee) Relayer->>Contract: CollectFee (future) ``` @@ -78,14 +78,15 @@ This repo contains an initial prototype of the betting protocol alongside the or workflows/ continuous-integration.yml validators/ - hello_world.ak # original placeholder, kept for reference - round_validator.ak # prototype betting logic: PlaceBet / Settle / Claim / Refund + hello_world.ak # original placeholder, kept for reference + round_validator.ak # betting logic: PlaceBet / Settle / Claim / Refund + round_validator_tests.ak # validator tests (22 tests) lib/ - types.ak # RoundDatum, RoundStatus, BetSide, Bet, Action - utils.ak # pool math, winner determination, payout calculation -env/ # empty + types.ak # RoundDatum, RoundStatus, BetSide, Bet, Action + utils.ak # pool math, winner determination, payout calculation + tests +env/ # empty aiken.toml -aiken.lock # pins aiken-lang/stdlib v3.0.0 for reproducible builds +aiken.lock # pins aiken-lang/stdlib v3.0.0 for reproducible builds plutus.json .gitignore README.md @@ -97,10 +98,11 @@ The prototype implements the core round mechanics. See **Design assumptions** fo ```text validators/ - round_validator.ak # Core betting logic: PlaceBet, Settle, Claim, Refund + round_validator.ak # Core betting logic: PlaceBet, Settle, Claim, Refund + round_validator_tests.ak # Validator tests (imports validate from round_validator) lib/ - types.ak # RoundDatum, RoundStatus, BetSide, Bet, Action - utils.ak # Pool math, winner determination, payout calculation + types.ak # RoundDatum, RoundStatus, BetSide, Bet, Action + utils.ak # Pool math, winner determination, payout calculation ``` ### Validator actions @@ -153,5 +155,5 @@ A GitHub Actions workflow at `.github/workflows/continuous-integration.yml` runs ## Resources - [Aiken user manual](https://aiken-lang.org) -- [Pyth Network on Cardano](https://docs.pyth.network/price-feeds/use-real-time-data/cardano) +- [Pyth Lazer documentation](https://docs.pyth.network/lazer) - [Cardano developer docs](https://developers.cardano.org) \ No newline at end of file diff --git a/lazer/cardano/5minBets/lib/types.ak b/lazer/cardano/5minBets/lib/types.ak new file mode 100644 index 00000000..6a7605dd --- /dev/null +++ b/lazer/cardano/5minBets/lib/types.ak @@ -0,0 +1,62 @@ +use aiken/crypto.{VerificationKeyHash} + +/// Pyth Lazer feed ID for XAU/USD (gold priced in US dollars). +/// Verify this against https://pyth.network/developers/price-feed-ids before deploying. +pub const xau_usd_feed_id: Int = 6 + +/// Minimum bet size in lovelace (2 ADA). +/// Prevents spam bets that bloat the on-chain datum. +pub const min_bet_lovelace: Int = 2000000 + +pub type BetSide { + Up + Down +} + +pub type RoundStatus { + Open + Settled { winner: BetSide } + Draw + Void +} + +pub type Bet { + bettor: VerificationKeyHash, + side: BetSide, + amount: Int, +} + +/// On-chain datum for a betting round. +/// +/// A round is opened by the relayer, who also closes it by submitting +/// the Pyth Lazer closing price for the configured feed. Winners claim +/// their proportional share of the pool minus a 2% protocol fee. +pub type RoundDatum { + /// Pyth Lazer feed ID that this round tracks (must equal xau_usd_feed_id). + feed_id: Int, + /// Key hash of the trusted relayer who opens and settles rounds. + relayer: VerificationKeyHash, + /// Pyth XAU/USD price at round open (scaled integer, e.g. price × 10^8). + open_price: Int, + /// Pyth XAU/USD price at round close — None while the round is open. + close_price: Option, + /// Current lifecycle state of the round. + status: RoundStatus, + /// POSIX time in milliseconds when the betting window closes. + deadline_ms: Int, + /// All bets placed in this round. + bets: List, + /// Bettors who have already collected their winnings or refunds. + claimed: List, +} + +pub type Action { + /// A user places a bet on a price direction before the deadline. + PlaceBet { bettor: VerificationKeyHash, side: BetSide } + /// The relayer submits the Pyth closing price to resolve the round. + Settle { close_price: Int } + /// A winner claims their proportional share of the pool (minus fee). + Claim { bettor: VerificationKeyHash } + /// A bettor recovers their full stake from a drawn or voided round. + Refund { bettor: VerificationKeyHash } +} diff --git a/lazer/cardano/5minBets/lib/utils.ak b/lazer/cardano/5minBets/lib/utils.ak new file mode 100644 index 00000000..7f25b72d --- /dev/null +++ b/lazer/cardano/5minBets/lib/utils.ak @@ -0,0 +1,121 @@ +use aiken/collection/list +use types.{Bet, BetSide, Down, Up} + +/// Protocol fee in basis points (200 = 2%). +pub const fee_bps: Int = 200 + +/// Sum of all stakes in the pool (lovelace). +pub fn total_pool(bets: List) -> Int { + list.foldl(bets, 0, fn(bet, acc) { acc + bet.amount }) +} + +/// Sum of stakes placed on a specific side. +pub fn side_pool(bets: List, side: BetSide) -> Int { + bets + |> list.filter(fn(b) { b.side == side }) + |> list.foldl(0, fn(b, acc) { acc + b.amount }) +} + +/// Determine the winning side from the opening and closing prices. +/// Returns None when the prices are equal (draw). +pub fn determine_winner(open_price: Int, close_price: Int) -> Option { + if close_price > open_price { + Some(Up) + } else if close_price < open_price { + Some(Down) + } else { + None + } +} + +/// Net payout for a winner after the protocol fee is deducted. +/// +/// Formula: (stake × total / winner_pool) × (1 − fee_bps / 10000) +pub fn net_payout(stake: Int, winner_pool: Int, total: Int) -> Int { + let gross = stake * total / winner_pool + let fee = gross * fee_bps / 10000 + gross - fee +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +fn make_bet(side: BetSide, amount: Int) -> Bet { + Bet { bettor: #"aa", side, amount } +} + +// --- total_pool --- + +test total_pool_empty() { + total_pool([]) == 0 +} + +test total_pool_single() { + total_pool([make_bet(Up, 1000000)]) == 1000000 +} + +test total_pool_multiple() { + let bets = [make_bet(Up, 3000000), make_bet(Down, 2000000), make_bet(Up, 1000000)] + total_pool(bets) == 6000000 +} + +// --- side_pool --- + +test side_pool_all_up() { + let bets = [make_bet(Up, 1000000), make_bet(Up, 2000000)] + side_pool(bets, Up) == 3000000 +} + +test side_pool_all_down() { + let bets = [make_bet(Down, 1000000), make_bet(Down, 500000)] + side_pool(bets, Down) == 1500000 +} + +test side_pool_mixed_counts_correct_side() { + let bets = [make_bet(Up, 4000000), make_bet(Down, 1000000), make_bet(Up, 2000000)] + side_pool(bets, Up) == 6000000 && side_pool(bets, Down) == 1000000 +} + +test side_pool_empty_side_returns_zero() { + let bets = [make_bet(Up, 3000000)] + side_pool(bets, Down) == 0 +} + +// --- determine_winner --- + +test determine_winner_price_rises() { + determine_winner(100, 110) == Some(Up) +} + +test determine_winner_price_falls() { + determine_winner(100, 90) == Some(Down) +} + +test determine_winner_price_unchanged() { + determine_winner(100, 100) == None +} + +// --- net_payout --- + +test net_payout_sole_winner_takes_all_minus_fee() { + // sole winner: gross = stake (100_000_000), fee = 2%, net = 98_000_000 + net_payout(100000000, 100000000, 100000000) == 98000000 +} + +test net_payout_proportional_two_sided() { + // bettor_a: stake = 10_000_000, winner_pool = 10_000_000, total = 15_000_000 + // gross = 10_000_000 * 15_000_000 / 10_000_000 = 15_000_000 + // fee = 15_000_000 * 200 / 10_000 = 300_000 + // net = 14_700_000 + net_payout(10000000, 10000000, 15000000) == 14700000 +} + +test net_payout_partial_stake() { + // bettor staked half the winning pool: gross = 2×stake, fee = 2% + // stake = 5_000_000, winner_pool = 10_000_000, total = 15_000_000 + // gross = 5_000_000 * 15_000_000 / 10_000_000 = 7_500_000 + // fee = 7_500_000 * 200 / 10_000 = 150_000 + // net = 7_350_000 + net_payout(5000000, 10000000, 15000000) == 7350000 +} diff --git a/lazer/cardano/5minBets/plutus.json b/lazer/cardano/5minBets/plutus.json index f1b697f8..a7e5c529 100644 --- a/lazer/cardano/5minBets/plutus.json +++ b/lazer/cardano/5minBets/plutus.json @@ -35,12 +35,75 @@ }, "compiledCode": "59010701010029800aba2aba1aab9faab9eaab9dab9a48888896600264646644b30013370e900118031baa00189919912cc004cdc3a400060126ea801626464b3001300f0028acc004cdc3a400060166ea800e2b30013371e6eb8c038c030dd5003a450d48656c6c6f2c20576f726c642100899199119801001000912cc00400629422b30013371e6eb8c04400400e2946266004004602400280690101bac300f30103010301030103010301030103010300d3754601e0146eb8c038c030dd5180718061baa0038a5040291640291640346eb8c034004c028dd5002c5900818050009805180580098039baa0018a504014600e002600e6010002600e00260066ea801e29344d95900101", "hash": "d36d6f01490e361d6944b224feecd3875751a05eebae444a30d4125c" + }, + { + "title": "round_validator.round_validator.spend", + "datum": { + "title": "datum", + "schema": { + "$ref": "#/definitions/types~1RoundDatum" + } + }, + "redeemer": { + "title": "redeemer", + "schema": { + "$ref": "#/definitions/types~1Action" + } + }, + "compiledCode": "590d3501010029800aba2aba1aba0aab9faab9eaab9dab9a488888896600264653001300800198041804800cdc3a400530080024888966002600460106ea800e3300130093754007370e90004dc3a4009370e90034c020dd50022444453001301100598089809002c88c966002600e00315980098089baa0038014590124566002601400315980098089baa00380145901245900f201e300f3754004911192cc004c0200162646644b30013019001899802980c0008024590161bae301600130170013012375401b1598009805802c4c8c96600260300050038b202a375a602c00260246ea80362b30013007005899192cc004c06000a0071640546eb8c058004c048dd5006c566002600c00b1323259800980c001400e2c80a8dd7180b00098091baa00d8b2020404080810100cc00488c8cc00400400c88cc00c004c00800a6e952000911919800800801912cc004006297ae0899912cc004c01400a2660320046600800800313300400400140546030002603200280b2444653001001802400d0011112cc00400a200319800801cc06c00a660086034004002801901848c054c05800646466446600400400244b3001001801c4c8cc896600266e452201000028acc004cdc7a441000028800c01901644cc014014c0700110161bae3015001375a602c002603000280b0c8c8cc004004010896600200300389919912cc004cdc8a441000028acc004cdc7a441000028800c01901744cc014014c0740110171bae30160013756602e002603200280b852f5bded8c02900048c054c058c058c058c05800644646600200200644b30010018a508acc004cdc79bae30180010038a51899801001180c800a026405923015301630163016301630163016301630160019180a980b180b180b180b180b180b180b000c8c054c058c058c058c058c058c0580064602a602c602c003230153016301630160019180a980b180b180b180b180b000c88c8cc00400400c8966002003100389980b980c00099801001180c800a02c488888888888888a600244646600200200644b30010018a6103d87a80008992cc004c010006260266604e00297ae08998018019814801204630270014095229800998078011192cc004c068c08cdd5000c4cdc79bae30273024375400200714a08110c098c08cdd5181318119baa001a40012233700002601c6eacc03cc090dd5001201c9192cc004c05cc084dd5000c4c966002603260446ea80062646464646464646530013758605c003375a605c011375c605c00f375a605c00d302e0049bad302e0039bac302e0024888888966002606c011132332259800981580144c8c96600260760050038b2070375a6072002606a6ea800e2b3001302e0028acc004c0d4dd5001c0062c81b22c819903318191baa00113322598009815800c566002606a6ea801a0051640d91598009817000c4c966002607400313302630390010038b206e3035375400d1598009815000c566002606a6ea801a0051640d91598009814800c566002606a6ea801a0051640d91640cc81990332066133022002225980080144cc090028896600200510158991801181e0019bae303a00240e1132598009816181a9baa001899191919912cc004c0fc00e266056607c0082600e607e0111640f06eb4c0f0004dd7181e001981e000981d800981b1baa0018b2068303800240d860646ea8010c0d40322c8198605c002605a0026058002605600260540026052002605000260466ea80062c8108c094c088dd5000c59020180298109baa001914c004cc03c0088cdd7980718119baa001002a400122337000026eb4c020c090dd5001201c48888c966002603660486ea805a26644b30013001375a6054604e6ea800a264b3001301e302737540031323322598009810800c4c8c966002604660586ea8c058c0b4dd500444c9660026048605a6ea80062646644b30013259800981598189baa001899b89375a606a60646ea8004dd6980a18191baa00d8a5040c0606860626ea8c070c0c4dd5180b98189baa0298acc004cc064dd6180c18189baa0290058acc004cdc42400000315980098059bad30343031375400515980099b8f375c603860626ea8008dd7180e18189baa00c8acc004cdc39bad3015303137540046eb4c054c0c4dd5006456600266ebcc050c0c4dd500126103d87a80008acc004cdd7980d18189baa0024c0103d87980008acc004cdc39bad3013303137540046eb4c04cc0c4dd5006456600266ebcc058c0c4dd50011ba7323322330020020012259800800c400e26606c606e00266004004607000281a8dd6180b98191baa00d33033301f33033375200a660666068606a00c660666ea00052f5c097ae0899baf301730313754004602e60626ea8032294102f4528205e8a5040bd14a0817a294102f4528205e8a5040bd14a0817a294102f4528205e300d0013370260326eacc068c0bcdd5000980c9bab301a302f37546034605e6ea801cc0c4c0b8dd5000c5902c198049bac3011302d375404a0091640ac6eb8c0bc004c0acdd50134566002604800313259800981118159baa3015302c375400f13259800981198161baa0018992cc004c9660026050605c6ea8006266e24dd6980898179baa00a375a6064605e6ea8006294102d181898171baa3031302e37546028605c6ea809a2b3001330163758602a605c6ea8098dd7180c98171baa0098acc004c020dd6981898171baa0018acc004cdc79bae3019302e37540026eb8c064c0b8dd5004c56600266e1cdd6980918171baa001375a6024605c6ea80262b30013375e6022605c6ea8004c070cc0c0dd4001a5eb822b30013375e602e605c6ea800566002b300130243300b37586026605c6ea8025300103d87980008a5189812198059bac3013302e375401298103d87a800040b114c103d87c80008992cc004c094c0b8dd5000c4cdd2a4004660626064605e6ea80052f5c114c0103d87b800040b464b30013371000200914c107d8799fd87980ff008acc004cdc4002000c530107d8799fd87a80ff008a6103d87a800040b48168dd6980918171baa00940b115980099b87375a6020605c6ea8004dd6980818171baa0098acc004cdd7980998171baa0013013302e375401313375e6028605c6ea800530010180008a5040b114a08162294102c452820588a5040b114a08162294102c452820588a5040b060166060605a6ea80062c8158cc020dd6180818161baa0240038b2054375a605c60566ea809a2b30013020001899912cc004c098c0b0dd5000c4c8c966002604a605c6ea8006264b30013026302f3754003132598009980c9bac30183031375405200d159800cc004cc064dd6180b98189baa00c006a50a5140bd15980099b89323370200266e0ccdc1000a41200690504e0099b83337046eb4c054c0c4dd5181a18189baa00398009bac301630313754019480024466e00004dd6980b98199baa00240746601c6eb0c058c0c4dd5006002198081bac30153031375405200d15980098059bad30343031375400315980099b8f375c603860626ea8004dd7180e18189baa00c8acc004cdc39bad3015303137540026eb4c054c0c4dd5006456600266ebcc050c0c4dd5000980a18189baa00c8acc004cdd7980d18189baa001301a3031375401915980099b87375a602660626ea8004dd6980998189baa00c8acc004cdd7980b18189baa00130163031375401913375e602e60626ea8004dd3998091bac301730313754018660666ea40192f5c114a0817a294102f4528205e8a5040bd14a0817a294102f4528205e8a5040bd14a0817a294102f1807181998181baa0018b205c3300b37586026605e6ea809c01a2c8168cc038dd6180998171baa00925980099b8f375c6064605e6ea8004012266ebcc068c0bcdd50008014528205a3030302d37540031640ac6eb8c0b8c0acdd5013180a18159baa006899192cc004c08cc0b0dd5000c4c9660026048605a6ea8006264b300132598009812800c528c566002604800314a314a0817102e18171baa3018302f37540151598009980b9bac3016302f375404e009159800cc004cc05cdd6180a98179baa00a004a50a5140b515980099b89375a6026605e6ea8c0c8c0bcdd5001998071bac3013302f375404e00915980098049bad3032302f375400315980099b8f375c6034605e6ea8004dd7180d18179baa00a8acc004cdc39bad3013302f37540026eb4c04cc0bcdd5005456600266ebcc048c0bcdd5000980918179baa00a8acc004cdd7980c18179baa0013018302f375401515980099b87375a6022605e6ea8004dd6980898179baa00a8acc004cdd7980a18179baa0013014302f375401513375e602a605e6ea8004dd3998081bac3015302f3754014660626ea40112f5c114a0816a294102d4528205a8a5040b514a0816a294102d4528205a8a5040b514a0816a294102d4528205a300c3031302e37540031640b0660126eb0c044c0b4dd501280245902b198061bac3011302c375400e466e3cdd7181818169baa001002375c605c60566ea8099029205240a4605860526ea8c050c0a4dd500098141baa024302b302837540031640986600e6eb0c0a8c09cdd500f919baf302b302837540020351640946050604a6ea8058dc3a401916408c446600c004466ebcc0a8c09cdd500080104528200e180400098019baa0088a4d1365640041", + "hash": "60205561b72ccd24d20bca1306baaeda1787e774757aa5b8dad42852" + }, + { + "title": "round_validator.round_validator.else", + "redeemer": { + "schema": {} + }, + "compiledCode": "590d3501010029800aba2aba1aba0aab9faab9eaab9dab9a488888896600264653001300800198041804800cdc3a400530080024888966002600460106ea800e3300130093754007370e90004dc3a4009370e90034c020dd50022444453001301100598089809002c88c966002600e00315980098089baa0038014590124566002601400315980098089baa00380145901245900f201e300f3754004911192cc004c0200162646644b30013019001899802980c0008024590161bae301600130170013012375401b1598009805802c4c8c96600260300050038b202a375a602c00260246ea80362b30013007005899192cc004c06000a0071640546eb8c058004c048dd5006c566002600c00b1323259800980c001400e2c80a8dd7180b00098091baa00d8b2020404080810100cc00488c8cc00400400c88cc00c004c00800a6e952000911919800800801912cc004006297ae0899912cc004c01400a2660320046600800800313300400400140546030002603200280b2444653001001802400d0011112cc00400a200319800801cc06c00a660086034004002801901848c054c05800646466446600400400244b3001001801c4c8cc896600266e452201000028acc004cdc7a441000028800c01901644cc014014c0700110161bae3015001375a602c002603000280b0c8c8cc004004010896600200300389919912cc004cdc8a441000028acc004cdc7a441000028800c01901744cc014014c0740110171bae30160013756602e002603200280b852f5bded8c02900048c054c058c058c058c05800644646600200200644b30010018a508acc004cdc79bae30180010038a51899801001180c800a026405923015301630163016301630163016301630160019180a980b180b180b180b180b180b180b000c8c054c058c058c058c058c058c0580064602a602c602c003230153016301630160019180a980b180b180b180b180b000c88c8cc00400400c8966002003100389980b980c00099801001180c800a02c488888888888888a600244646600200200644b30010018a6103d87a80008992cc004c010006260266604e00297ae08998018019814801204630270014095229800998078011192cc004c068c08cdd5000c4cdc79bae30273024375400200714a08110c098c08cdd5181318119baa001a40012233700002601c6eacc03cc090dd5001201c9192cc004c05cc084dd5000c4c966002603260446ea80062646464646464646530013758605c003375a605c011375c605c00f375a605c00d302e0049bad302e0039bac302e0024888888966002606c011132332259800981580144c8c96600260760050038b2070375a6072002606a6ea800e2b3001302e0028acc004c0d4dd5001c0062c81b22c819903318191baa00113322598009815800c566002606a6ea801a0051640d91598009817000c4c966002607400313302630390010038b206e3035375400d1598009815000c566002606a6ea801a0051640d91598009814800c566002606a6ea801a0051640d91640cc81990332066133022002225980080144cc090028896600200510158991801181e0019bae303a00240e1132598009816181a9baa001899191919912cc004c0fc00e266056607c0082600e607e0111640f06eb4c0f0004dd7181e001981e000981d800981b1baa0018b2068303800240d860646ea8010c0d40322c8198605c002605a0026058002605600260540026052002605000260466ea80062c8108c094c088dd5000c59020180298109baa001914c004cc03c0088cdd7980718119baa001002a400122337000026eb4c020c090dd5001201c48888c966002603660486ea805a26644b30013001375a6054604e6ea800a264b3001301e302737540031323322598009810800c4c8c966002604660586ea8c058c0b4dd500444c9660026048605a6ea80062646644b30013259800981598189baa001899b89375a606a60646ea8004dd6980a18191baa00d8a5040c0606860626ea8c070c0c4dd5180b98189baa0298acc004cc064dd6180c18189baa0290058acc004cdc42400000315980098059bad30343031375400515980099b8f375c603860626ea8008dd7180e18189baa00c8acc004cdc39bad3015303137540046eb4c054c0c4dd5006456600266ebcc050c0c4dd500126103d87a80008acc004cdd7980d18189baa0024c0103d87980008acc004cdc39bad3013303137540046eb4c04cc0c4dd5006456600266ebcc058c0c4dd50011ba7323322330020020012259800800c400e26606c606e00266004004607000281a8dd6180b98191baa00d33033301f33033375200a660666068606a00c660666ea00052f5c097ae0899baf301730313754004602e60626ea8032294102f4528205e8a5040bd14a0817a294102f4528205e8a5040bd14a0817a294102f4528205e300d0013370260326eacc068c0bcdd5000980c9bab301a302f37546034605e6ea801cc0c4c0b8dd5000c5902c198049bac3011302d375404a0091640ac6eb8c0bc004c0acdd50134566002604800313259800981118159baa3015302c375400f13259800981198161baa0018992cc004c9660026050605c6ea8006266e24dd6980898179baa00a375a6064605e6ea8006294102d181898171baa3031302e37546028605c6ea809a2b3001330163758602a605c6ea8098dd7180c98171baa0098acc004c020dd6981898171baa0018acc004cdc79bae3019302e37540026eb8c064c0b8dd5004c56600266e1cdd6980918171baa001375a6024605c6ea80262b30013375e6022605c6ea8004c070cc0c0dd4001a5eb822b30013375e602e605c6ea800566002b300130243300b37586026605c6ea8025300103d87980008a5189812198059bac3013302e375401298103d87a800040b114c103d87c80008992cc004c094c0b8dd5000c4cdd2a4004660626064605e6ea80052f5c114c0103d87b800040b464b30013371000200914c107d8799fd87980ff008acc004cdc4002000c530107d8799fd87a80ff008a6103d87a800040b48168dd6980918171baa00940b115980099b87375a6020605c6ea8004dd6980818171baa0098acc004cdd7980998171baa0013013302e375401313375e6028605c6ea800530010180008a5040b114a08162294102c452820588a5040b114a08162294102c452820588a5040b060166060605a6ea80062c8158cc020dd6180818161baa0240038b2054375a605c60566ea809a2b30013020001899912cc004c098c0b0dd5000c4c8c966002604a605c6ea8006264b30013026302f3754003132598009980c9bac30183031375405200d159800cc004cc064dd6180b98189baa00c006a50a5140bd15980099b89323370200266e0ccdc1000a41200690504e0099b83337046eb4c054c0c4dd5181a18189baa00398009bac301630313754019480024466e00004dd6980b98199baa00240746601c6eb0c058c0c4dd5006002198081bac30153031375405200d15980098059bad30343031375400315980099b8f375c603860626ea8004dd7180e18189baa00c8acc004cdc39bad3015303137540026eb4c054c0c4dd5006456600266ebcc050c0c4dd5000980a18189baa00c8acc004cdd7980d18189baa001301a3031375401915980099b87375a602660626ea8004dd6980998189baa00c8acc004cdd7980b18189baa00130163031375401913375e602e60626ea8004dd3998091bac301730313754018660666ea40192f5c114a0817a294102f4528205e8a5040bd14a0817a294102f4528205e8a5040bd14a0817a294102f1807181998181baa0018b205c3300b37586026605e6ea809c01a2c8168cc038dd6180998171baa00925980099b8f375c6064605e6ea8004012266ebcc068c0bcdd50008014528205a3030302d37540031640ac6eb8c0b8c0acdd5013180a18159baa006899192cc004c08cc0b0dd5000c4c9660026048605a6ea8006264b300132598009812800c528c566002604800314a314a0817102e18171baa3018302f37540151598009980b9bac3016302f375404e009159800cc004cc05cdd6180a98179baa00a004a50a5140b515980099b89375a6026605e6ea8c0c8c0bcdd5001998071bac3013302f375404e00915980098049bad3032302f375400315980099b8f375c6034605e6ea8004dd7180d18179baa00a8acc004cdc39bad3013302f37540026eb4c04cc0bcdd5005456600266ebcc048c0bcdd5000980918179baa00a8acc004cdd7980c18179baa0013018302f375401515980099b87375a6022605e6ea8004dd6980898179baa00a8acc004cdd7980a18179baa0013014302f375401513375e602a605e6ea8004dd3998081bac3015302f3754014660626ea40112f5c114a0816a294102d4528205a8a5040b514a0816a294102d4528205a8a5040b514a0816a294102d4528205a300c3031302e37540031640b0660126eb0c044c0b4dd501280245902b198061bac3011302c375400e466e3cdd7181818169baa001002375c605c60566ea8099029205240a4605860526ea8c050c0a4dd500098141baa024302b302837540031640986600e6eb0c0a8c09cdd500f919baf302b302837540020351640946050604a6ea8058dc3a401916408c446600c004466ebcc0a8c09cdd500080104528200e180400098019baa0088a4d1365640041", + "hash": "60205561b72ccd24d20bca1306baaeda1787e774757aa5b8dad42852" } ], "definitions": { "ByteArray": { "dataType": "bytes" }, + "Int": { + "dataType": "integer" + }, + "List": { + "dataType": "list", + "items": { + "$ref": "#/definitions/aiken~1crypto~1VerificationKeyHash" + } + }, + "List": { + "dataType": "list", + "items": { + "$ref": "#/definitions/types~1Bet" + } + }, + "Option": { + "title": "Option", + "anyOf": [ + { + "title": "Some", + "description": "An optional value.", + "dataType": "constructor", + "index": 0, + "fields": [ + { + "$ref": "#/definitions/Int" + } + ] + }, + { + "title": "None", + "description": "Nothing.", + "dataType": "constructor", + "index": 1, + "fields": [] + } + ] + }, "aiken/crypto/VerificationKeyHash": { "title": "VerificationKeyHash", "dataType": "bytes" @@ -76,6 +139,191 @@ ] } ] + }, + "types/Action": { + "title": "Action", + "anyOf": [ + { + "title": "PlaceBet", + "description": "A user places a bet on a price direction before the deadline.", + "dataType": "constructor", + "index": 0, + "fields": [ + { + "title": "bettor", + "$ref": "#/definitions/aiken~1crypto~1VerificationKeyHash" + }, + { + "title": "side", + "$ref": "#/definitions/types~1BetSide" + } + ] + }, + { + "title": "Settle", + "description": "The relayer submits the Pyth closing price to resolve the round.", + "dataType": "constructor", + "index": 1, + "fields": [ + { + "title": "close_price", + "$ref": "#/definitions/Int" + } + ] + }, + { + "title": "Claim", + "description": "A winner claims their proportional share of the pool (minus fee).", + "dataType": "constructor", + "index": 2, + "fields": [ + { + "title": "bettor", + "$ref": "#/definitions/aiken~1crypto~1VerificationKeyHash" + } + ] + }, + { + "title": "Refund", + "description": "A bettor recovers their full stake from a drawn or voided round.", + "dataType": "constructor", + "index": 3, + "fields": [ + { + "title": "bettor", + "$ref": "#/definitions/aiken~1crypto~1VerificationKeyHash" + } + ] + } + ] + }, + "types/Bet": { + "title": "Bet", + "anyOf": [ + { + "title": "Bet", + "dataType": "constructor", + "index": 0, + "fields": [ + { + "title": "bettor", + "$ref": "#/definitions/aiken~1crypto~1VerificationKeyHash" + }, + { + "title": "side", + "$ref": "#/definitions/types~1BetSide" + }, + { + "title": "amount", + "$ref": "#/definitions/Int" + } + ] + } + ] + }, + "types/BetSide": { + "title": "BetSide", + "anyOf": [ + { + "title": "Up", + "dataType": "constructor", + "index": 0, + "fields": [] + }, + { + "title": "Down", + "dataType": "constructor", + "index": 1, + "fields": [] + } + ] + }, + "types/RoundDatum": { + "title": "RoundDatum", + "description": "On-chain datum for a betting round.\n\n A round is opened by the relayer, who also closes it by submitting\n the Pyth Lazer closing price for the configured feed. Winners claim\n their proportional share of the pool minus a 2% protocol fee.", + "anyOf": [ + { + "title": "RoundDatum", + "dataType": "constructor", + "index": 0, + "fields": [ + { + "title": "feed_id", + "description": "Pyth Lazer feed ID that this round tracks (must equal xau_usd_feed_id).", + "$ref": "#/definitions/Int" + }, + { + "title": "relayer", + "description": "Key hash of the trusted relayer who opens and settles rounds.", + "$ref": "#/definitions/aiken~1crypto~1VerificationKeyHash" + }, + { + "title": "open_price", + "description": "Pyth XAU/USD price at round open (scaled integer, e.g. price × 10^8).", + "$ref": "#/definitions/Int" + }, + { + "title": "close_price", + "description": "Pyth XAU/USD price at round close — None while the round is open.", + "$ref": "#/definitions/Option" + }, + { + "title": "status", + "description": "Current lifecycle state of the round.", + "$ref": "#/definitions/types~1RoundStatus" + }, + { + "title": "deadline_ms", + "description": "POSIX time in milliseconds when the betting window closes.", + "$ref": "#/definitions/Int" + }, + { + "title": "bets", + "description": "All bets placed in this round.", + "$ref": "#/definitions/List" + }, + { + "title": "claimed", + "description": "Bettors who have already collected their winnings or refunds.", + "$ref": "#/definitions/List" + } + ] + } + ] + }, + "types/RoundStatus": { + "title": "RoundStatus", + "anyOf": [ + { + "title": "Open", + "dataType": "constructor", + "index": 0, + "fields": [] + }, + { + "title": "Settled", + "dataType": "constructor", + "index": 1, + "fields": [ + { + "title": "winner", + "$ref": "#/definitions/types~1BetSide" + } + ] + }, + { + "title": "Draw", + "dataType": "constructor", + "index": 2, + "fields": [] + }, + { + "title": "Void", + "dataType": "constructor", + "index": 3, + "fields": [] + } + ] } } } \ No newline at end of file diff --git a/lazer/cardano/5minBets/validators/hello_world.ak b/lazer/cardano/5minBets/validators/hello_world.ak deleted file mode 100644 index 54372d1e..00000000 --- a/lazer/cardano/5minBets/validators/hello_world.ak +++ /dev/null @@ -1,30 +0,0 @@ -use aiken/collection/list -use aiken/crypto.{VerificationKeyHash} -use cardano/script_context.{ScriptContext} -use cardano/transaction.{OutputReference, Transaction} - -pub type Datum { - owner: VerificationKeyHash, -} - -pub type Redeemer { - msg: ByteArray, -} - -validator hello_world { - spend( - datum: Option, - redeemer: Redeemer, - _own_ref: OutputReference, - self: Transaction, - ) { - expect Some(Datum { owner }) = datum - let must_say_hello = redeemer.msg == "Hello, World!" - let must_be_signed = list.has(self.extra_signatories, owner) - must_say_hello && must_be_signed - } - - else(_ctx: ScriptContext) { - False - } -} \ No newline at end of file diff --git a/lazer/cardano/5minBets/validators/round_validator.ak b/lazer/cardano/5minBets/validators/round_validator.ak new file mode 100644 index 00000000..d17fd347 --- /dev/null +++ b/lazer/cardano/5minBets/validators/round_validator.ak @@ -0,0 +1,252 @@ +use aiken/collection/list +use aiken/crypto.{VerificationKeyHash} +use aiken/interval.{Finite} +use cardano/address.{Address, VerificationKey} +use cardano/assets.{lovelace_of} +use cardano/script_context.{ScriptContext} +use cardano/transaction.{ + InlineDatum, Input, Output, OutputReference, Transaction, +} +use types.{ + Action, Bet, Claim, Down, Draw, Open, PlaceBet, Refund, RoundDatum, Settle, + Settled, Up, Void, min_bet_lovelace, xau_usd_feed_id, +} +use utils + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +fn find_own_input(inputs: List, own_ref: OutputReference) -> Option { + list.find(inputs, fn(i) { i.output_reference == own_ref }) +} + +fn find_script_output(outputs: List, script_addr: Address) -> Option { + list.find(outputs, fn(o) { o.address == script_addr }) +} + +/// Total lovelace sent to a verification-key address in this transaction. +fn bettor_received(outputs: List, vkh: VerificationKeyHash) -> Int { + outputs + |> list.filter( + fn(o) { + when o.address.payment_credential is { + VerificationKey(k) -> k == vkh + _ -> False + } + }, + ) + |> list.foldl(0, fn(o, acc) { acc + lovelace_of(o.value) }) +} + +/// Decode an inline datum from an output as a RoundDatum. +fn decode_datum(output: Output) -> RoundDatum { + expect InlineDatum(raw) = output.datum + expect d: RoundDatum = raw + d +} + +// --------------------------------------------------------------------------- +// Core validation logic (extracted for testability) +// --------------------------------------------------------------------------- + +pub fn validate( + datum: Option, + redeemer: Action, + own_ref: OutputReference, + self: Transaction, +) -> Bool { + expect Some(d) = datum + + // Reject any round not tracking the XAU/USD Pyth Lazer feed. + expect d.feed_id == xau_usd_feed_id + + expect Some(own_input) = find_own_input(self.inputs, own_ref) + let script_address = own_input.output.address + let own_lovelace = lovelace_of(own_input.output.value) + + when redeemer is { + // ----------------------------------------------------------------------- + // PlaceBet — user locks ADA at the script and records their bet choice + // + // Checks: + // • Round is Open + // • Transaction upper-bound is at or before the deadline + // • Bettor has signed the transaction + // • Continued script output carries the updated datum (bet appended) + // • Continued output value increased by the bet amount + // ----------------------------------------------------------------------- + PlaceBet { bettor, side } -> { + expect Open = d.status + + let before_deadline = + when self.validity_range.upper_bound.bound_type is { + Finite(t) -> t <= d.deadline_ms + _ -> False + } + + let bettor_signed = list.has(self.extra_signatories, bettor) + + expect Some(cont_out) = find_script_output(self.outputs, script_address) + let new_d = decode_datum(cont_out) + + let bet_amount = lovelace_of(cont_out.value) - own_lovelace + let new_bet = Bet { bettor, side, amount: bet_amount } + + let datum_ok = + new_d.feed_id == xau_usd_feed_id && new_d.relayer == d.relayer && new_d.open_price == d.open_price && new_d.close_price == None && new_d.status == Open && new_d.deadline_ms == d.deadline_ms && new_d.bets == list.concat( + d.bets, + [new_bet], + ) && new_d.claimed == d.claimed + + before_deadline && bettor_signed && bet_amount >= min_bet_lovelace && datum_ok + } + + // ----------------------------------------------------------------------- + // Settle — relayer submits the Pyth closing price after the deadline + // + // Checks: + // • Round is Open + // • Transaction lower-bound is at or after the deadline + // • Relayer has signed the transaction + // • Continued output records close_price and updated status + // + // NOTE: In production, verify close_price against a Pyth reference input + // rather than trusting the relayer unconditionally. + // ----------------------------------------------------------------------- + Settle { close_price } -> { + expect Open = d.status + + let after_deadline = + when self.validity_range.lower_bound.bound_type is { + Finite(t) -> t >= d.deadline_ms + _ -> False + } + + let relayer_signed = list.has(self.extra_signatories, d.relayer) + + expect Some(cont_out) = find_script_output(self.outputs, script_address) + let new_d = decode_datum(cont_out) + + // M3: Require at least one bet before settlement. + expect utils.total_pool(d.bets) > 0 + + // If one side has no bets, the round is voided instead of settled. + let up_pool = utils.side_pool(d.bets, Up) + let down_pool = utils.side_pool(d.bets, Down) + let new_status = + if up_pool == 0 || down_pool == 0 { + Void + } else { + when utils.determine_winner(d.open_price, close_price) is { + Some(winner) -> Settled { winner } + None -> Draw + } + } + + let datum_ok = + new_d.feed_id == xau_usd_feed_id && new_d.relayer == d.relayer && new_d.open_price == d.open_price && new_d.close_price == Some( + close_price, + ) && new_d.status == new_status && new_d.deadline_ms == d.deadline_ms && new_d.bets == d.bets && new_d.claimed == [] + + after_deadline && relayer_signed && datum_ok + } + + // ----------------------------------------------------------------------- + // Claim — a winner collects their share of the pool + // + // Checks: + // • Round is Settled and bettor bet on the winning side + // • Bettor has not already claimed + // • Bettor receives at least their net_payout (proportional, minus fee) + // • Continued datum records bettor as claimed + // ----------------------------------------------------------------------- + Claim { bettor } -> { + expect Settled { winner } = d.status + + let bettor_signed = list.has(self.extra_signatories, bettor) + let not_yet_claimed = !list.has(d.claimed, bettor) + + expect Some(bet) = + list.find(d.bets, fn(b) { b.bettor == bettor && b.side == winner }) + + let winner_pool = utils.side_pool(d.bets, winner) + let total = utils.total_pool(d.bets) + let expected = utils.net_payout(bet.amount, winner_pool, total) + + let paid = bettor_received(self.outputs, bettor) + + expect Some(cont_out) = find_script_output(self.outputs, script_address) + let new_d = decode_datum(cont_out) + + let datum_ok = + new_d.feed_id == xau_usd_feed_id && new_d.relayer == d.relayer && new_d.open_price == d.open_price && new_d.close_price == d.close_price && new_d.status == d.status && new_d.deadline_ms == d.deadline_ms && new_d.bets == d.bets && new_d.claimed == list.concat( + d.claimed, + [bettor], + ) + + // C4: Script output must lose exactly the claimed amount — no pool drain. + let script_value_ok = lovelace_of(cont_out.value) >= own_lovelace - expected + + bettor_signed && not_yet_claimed && paid >= expected && script_value_ok && datum_ok + } + + // ----------------------------------------------------------------------- + // Refund — a bettor recovers their full stake from a Draw or Void round + // + // Checks: + // • Round is in a refundable state (Draw or Void) + // • Bettor has not already been refunded + // • Bettor receives at least their original stake back + // • Continued datum records bettor as claimed + // ----------------------------------------------------------------------- + Refund { bettor } -> { + let is_refundable = + when d.status is { + Draw -> True + Void -> True + _ -> False + } + + let bettor_signed = list.has(self.extra_signatories, bettor) + let not_yet_claimed = !list.has(d.claimed, bettor) + + expect Some(bet) = list.find(d.bets, fn(b) { b.bettor == bettor }) + + let paid = bettor_received(self.outputs, bettor) + + expect Some(cont_out) = find_script_output(self.outputs, script_address) + let new_d = decode_datum(cont_out) + + let datum_ok = + new_d.feed_id == xau_usd_feed_id && new_d.relayer == d.relayer && new_d.open_price == d.open_price && new_d.close_price == d.close_price && new_d.status == d.status && new_d.deadline_ms == d.deadline_ms && new_d.bets == d.bets && new_d.claimed == list.concat( + d.claimed, + [bettor], + ) + + // C4: Script output must lose exactly the refunded stake — no pool drain. + let script_value_ok = lovelace_of(cont_out.value) >= own_lovelace - bet.amount + + is_refundable && bettor_signed && not_yet_claimed && paid >= bet.amount && script_value_ok && datum_ok + } + } +} + +// --------------------------------------------------------------------------- +// Validator entry point +// --------------------------------------------------------------------------- + +validator round_validator { + spend( + datum: Option, + redeemer: Action, + own_ref: OutputReference, + self: Transaction, + ) { + validate(datum, redeemer, own_ref, self) + } + + else(_ctx: ScriptContext) { + False + } +} diff --git a/lazer/cardano/5minBets/validators/round_validator_tests.ak b/lazer/cardano/5minBets/validators/round_validator_tests.ak new file mode 100644 index 00000000..37b3c9e1 --- /dev/null +++ b/lazer/cardano/5minBets/validators/round_validator_tests.ak @@ -0,0 +1,568 @@ +use aiken/collection/dict +use aiken/interval.{Finite, Interval, IntervalBound} +use cardano/address.{Address, from_script, from_verification_key} +use cardano/assets.{from_lovelace, zero} +use cardano/transaction.{ + InlineDatum, Input, NoDatum, Output, OutputReference, Transaction, +} +use round_validator.{validate} +use types.{ + Bet, Claim, Down, Draw, Open, PlaceBet, Refund, RoundDatum, Settle, Settled, + Up, Void, xau_usd_feed_id, +} + +// --------------------------------------------------------------------------- +// Test fixtures +// --------------------------------------------------------------------------- + +const relayer: ByteArray = #"aa" + +const bettor_a: ByteArray = #"bb" + +const bettor_b: ByteArray = #"cc" + +const s_hash: ByteArray = #"dd" + +const deadline: Int = 1000000 + +const open_price: Int = 300000000000 + +fn mock_ref(idx: Int) -> OutputReference { + OutputReference { transaction_id: #"0011", output_index: idx } +} + +fn s_addr() -> Address { + from_script(s_hash) +} + +/// Construct a Transaction with Finite validity bounds and specific signatories. +fn mock_tx( + inputs: List, + outputs: List, + signatories: List, + lower_ms: Int, + upper_ms: Int, +) -> Transaction { + Transaction { + inputs, + reference_inputs: [], + outputs, + fee: 0, + mint: zero, + certificates: [], + withdrawals: [], + validity_range: Interval { + lower_bound: IntervalBound { + bound_type: Finite(lower_ms), + is_inclusive: True, + }, + upper_bound: IntervalBound { + bound_type: Finite(upper_ms), + is_inclusive: True, + }, + }, + extra_signatories: signatories, + redeemers: [], + datums: dict.empty, + id: #"0011", + votes: [], + proposal_procedures: [], + current_treasury_amount: None, + treasury_donation: None, + } +} + +/// Build the script UTxO input being spent. +fn script_in(datum: RoundDatum, lovelace: Int) -> Input { + Input { + output_reference: mock_ref(0), + output: Output { + address: s_addr(), + value: from_lovelace(lovelace), + datum: InlineDatum(datum), + reference_script: None, + }, + } +} + +/// Build a continued script output. +fn script_out(datum: RoundDatum, lovelace: Int) -> Output { + Output { + address: s_addr(), + value: from_lovelace(lovelace), + datum: InlineDatum(datum), + reference_script: None, + } +} + +/// Build a plain verification-key output (for payouts). +fn bettor_out(vkh: ByteArray, lovelace: Int) -> Output { + Output { + address: from_verification_key(vkh), + value: from_lovelace(lovelace), + datum: NoDatum, + reference_script: None, + } +} + +/// Open round with no bets. +fn d_open() -> RoundDatum { + RoundDatum { + feed_id: xau_usd_feed_id, + relayer, + open_price, + close_price: None, + status: Open, + deadline_ms: deadline, + bets: [], + claimed: [], + } +} + +/// Open round with one UP bet and one DOWN bet. +fn d_with_bets() -> RoundDatum { + RoundDatum { + ..d_open(), + bets: [ + Bet { bettor: bettor_a, side: Up, amount: 10000000 }, + Bet { bettor: bettor_b, side: Down, amount: 5000000 }, + ], + } +} + +/// Settled round where UP won. +fn d_settled_up() -> RoundDatum { + RoundDatum { + ..d_with_bets(), + close_price: Some(310000000000), + status: Settled { winner: Up }, + claimed: [], + } +} + +// --------------------------------------------------------------------------- +// PlaceBet tests +// --------------------------------------------------------------------------- + +test place_bet_valid() { + let d = d_open() + let bet_amount = 5000000 + let new_d = + RoundDatum { + ..d, + bets: [Bet { bettor: bettor_a, side: Up, amount: bet_amount }], + } + let tx = + mock_tx( + [script_in(d, 2000000)], + [script_out(new_d, 2000000 + bet_amount)], + [bettor_a], + 0, + 800000, + ) + validate(Some(d), PlaceBet { bettor: bettor_a, side: Up }, mock_ref(0), tx) +} + +test place_bet_after_deadline_fails() fail { + let d = d_open() + let bet_amount = 5000000 + let new_d = + RoundDatum { + ..d, + bets: [Bet { bettor: bettor_a, side: Up, amount: bet_amount }], + } + // upper_ms (1500000) > deadline (1000000) — must fail + let tx = + mock_tx( + [script_in(d, 2000000)], + [script_out(new_d, 2000000 + bet_amount)], + [bettor_a], + 1200000, + 1500000, + ) + validate(Some(d), PlaceBet { bettor: bettor_a, side: Up }, mock_ref(0), tx) +} + +test place_bet_unsigned_fails() fail { + let d = d_open() + let bet_amount = 5000000 + let new_d = + RoundDatum { + ..d, + bets: [Bet { bettor: bettor_a, side: Up, amount: bet_amount }], + } + let tx = + mock_tx( + [script_in(d, 2000000)], + [script_out(new_d, 2000000 + bet_amount)], + [], + 0, + 800000, + ) + validate(Some(d), PlaceBet { bettor: bettor_a, side: Up }, mock_ref(0), tx) +} + +test place_bet_wrong_feed_id_fails() fail { + // feed_id mismatch — validator must reject at the top-level guard + let d = RoundDatum { ..d_open(), feed_id: 99 } + let tx = mock_tx([], [], [bettor_a], 0, 800000) + validate(Some(d), PlaceBet { bettor: bettor_a, side: Up }, mock_ref(0), tx) +} + +// H5: bet below 2 ADA minimum must be rejected +test place_bet_below_minimum_fails() fail { + let d = d_open() + let bet_amount = 1 + let new_d = + RoundDatum { + ..d, + bets: [Bet { bettor: bettor_a, side: Up, amount: bet_amount }], + } + let tx = + mock_tx( + [script_in(d, 2000000)], + [script_out(new_d, 2000000 + bet_amount)], + [bettor_a], + 0, + 800000, + ) + validate(Some(d), PlaceBet { bettor: bettor_a, side: Up }, mock_ref(0), tx) +} + +// --------------------------------------------------------------------------- +// Settle tests +// --------------------------------------------------------------------------- + +test settle_up_wins() { + let d = d_with_bets() + let close = 310000000000 + let new_d = + RoundDatum { + ..d, + close_price: Some(close), + status: Settled { winner: Up }, + claimed: [], + } + let tx = + mock_tx( + [script_in(d, 2000000)], + [script_out(new_d, 2000000)], + [relayer], + 1100000, + 2000000, + ) + validate(Some(d), Settle { close_price: close }, mock_ref(0), tx) +} + +test settle_down_wins() { + let d = d_with_bets() + let close = 290000000000 + let new_d = + RoundDatum { + ..d, + close_price: Some(close), + status: Settled { winner: Down }, + claimed: [], + } + let tx = + mock_tx( + [script_in(d, 2000000)], + [script_out(new_d, 2000000)], + [relayer], + 1100000, + 2000000, + ) + validate(Some(d), Settle { close_price: close }, mock_ref(0), tx) +} + +test settle_draw() { + let d = d_with_bets() + let close = open_price + let new_d = + RoundDatum { ..d, close_price: Some(close), status: Draw, claimed: [] } + let tx = + mock_tx( + [script_in(d, 2000000)], + [script_out(new_d, 2000000)], + [relayer], + 1100000, + 2000000, + ) + validate(Some(d), Settle { close_price: close }, mock_ref(0), tx) +} + +test settle_void_when_no_up_bets() { + // Only DOWN bets → round must become Void regardless of price movement + let d = + RoundDatum { + ..d_open(), + bets: [Bet { bettor: bettor_b, side: Down, amount: 5000000 }], + } + let close = 310000000000 + let new_d = + RoundDatum { ..d, close_price: Some(close), status: Void, claimed: [] } + let tx = + mock_tx( + [script_in(d, 2000000)], + [script_out(new_d, 2000000)], + [relayer], + 1100000, + 2000000, + ) + validate(Some(d), Settle { close_price: close }, mock_ref(0), tx) +} + +test settle_before_deadline_fails() fail { + let d = d_with_bets() + let close = 310000000000 + let new_d = + RoundDatum { + ..d, + close_price: Some(close), + status: Settled { winner: Up }, + claimed: [], + } + // lower_ms (0) < deadline (1000000) — must fail + let tx = + mock_tx( + [script_in(d, 2000000)], + [script_out(new_d, 2000000)], + [relayer], + 0, + 800000, + ) + validate(Some(d), Settle { close_price: close }, mock_ref(0), tx) +} + +test settle_relayer_unsigned_fails() fail { + let d = d_with_bets() + let close = 310000000000 + let new_d = + RoundDatum { + ..d, + close_price: Some(close), + status: Settled { winner: Up }, + claimed: [], + } + let tx = + mock_tx( + [script_in(d, 2000000)], + [script_out(new_d, 2000000)], + [], + 1100000, + 2000000, + ) + validate(Some(d), Settle { close_price: close }, mock_ref(0), tx) +} + +// M3: settling a round with no bets must be rejected +test settle_empty_round_fails() fail { + let d = d_open() + let close = 310000000000 + let new_d = + RoundDatum { ..d, close_price: Some(close), status: Void, claimed: [] } + let tx = + mock_tx( + [script_in(d, 2000000)], + [script_out(new_d, 2000000)], + [relayer], + 1100000, + 2000000, + ) + validate(Some(d), Settle { close_price: close }, mock_ref(0), tx) +} + +// --------------------------------------------------------------------------- +// Claim tests +// --------------------------------------------------------------------------- + +// bettor_a staked 10_000_000 on UP; total pool = 15_000_000; winner_pool = 10_000_000 +// gross = 10_000_000 * 15_000_000 / 10_000_000 = 15_000_000 +// fee = 15_000_000 * 200 / 10_000 = 300_000 +// net = 14_700_000 +const expected_payout_a: Int = 14700000 + +test claim_winner_valid() { + let d = d_settled_up() + let new_d = RoundDatum { ..d, claimed: [bettor_a] } + let pool_lovelace = 17000000 + let tx = + mock_tx( + [script_in(d, pool_lovelace)], + [ + bettor_out(bettor_a, expected_payout_a), + script_out(new_d, pool_lovelace - expected_payout_a), + ], + [bettor_a], + 1100000, + 2000000, + ) + validate(Some(d), Claim { bettor: bettor_a }, mock_ref(0), tx) +} + +test claim_loser_fails() fail { + // bettor_b bet DOWN but UP won — claim must fail + let d = d_settled_up() + let new_d = RoundDatum { ..d, claimed: [bettor_b] } + let tx = + mock_tx( + [script_in(d, 17000000)], + [bettor_out(bettor_b, 5000000), script_out(new_d, 12000000)], + [bettor_b], + 1100000, + 2000000, + ) + validate(Some(d), Claim { bettor: bettor_b }, mock_ref(0), tx) +} + +test claim_already_claimed_fails() fail { + // bettor_a already in claimed list — must fail + let d = RoundDatum { ..d_settled_up(), claimed: [bettor_a] } + let tx = + mock_tx( + [script_in(d, 17000000)], + [script_out(d, 17000000)], + [bettor_a], + 1100000, + 2000000, + ) + validate(Some(d), Claim { bettor: bettor_a }, mock_ref(0), tx) +} + +test claim_underpaid_fails() fail { + let d = d_settled_up() + let new_d = RoundDatum { ..d, claimed: [bettor_a] } + let underpaid = expected_payout_a - 1 + let tx = + mock_tx( + [script_in(d, 17000000)], + [bettor_out(bettor_a, underpaid), script_out(new_d, 17000000 - underpaid)], + [bettor_a], + 1100000, + 2000000, + ) + validate(Some(d), Claim { bettor: bettor_a }, mock_ref(0), tx) +} + +// C4: attacker pays winner the expected amount from their own wallet but drains +// the script output to zero — script_value_ok guard must catch this +test claim_overdrain_fails() fail { + let d = d_settled_up() + let new_d = RoundDatum { ..d, claimed: [bettor_a] } + let pool_lovelace = 17000000 + // Script output is 0 — entire pool stolen by attacker + let tx = + mock_tx( + [script_in(d, pool_lovelace)], + [bettor_out(bettor_a, expected_payout_a), script_out(new_d, 0)], + [bettor_a], + 1100000, + 2000000, + ) + validate(Some(d), Claim { bettor: bettor_a }, mock_ref(0), tx) +} + +// --------------------------------------------------------------------------- +// Refund tests +// --------------------------------------------------------------------------- + +test refund_draw_valid() { + let d = + RoundDatum { + ..d_with_bets(), + close_price: Some(open_price), + status: Draw, + claimed: [], + } + let stake_a = 10000000 + let new_d = RoundDatum { ..d, claimed: [bettor_a] } + let pool = 17000000 + let tx = + mock_tx( + [script_in(d, pool)], + [bettor_out(bettor_a, stake_a), script_out(new_d, pool - stake_a)], + [bettor_a], + 1100000, + 2000000, + ) + validate(Some(d), Refund { bettor: bettor_a }, mock_ref(0), tx) +} + +test refund_void_valid() { + let d = + RoundDatum { + ..d_open(), + bets: [Bet { bettor: bettor_a, side: Down, amount: 5000000 }], + close_price: Some(310000000000), + status: Void, + claimed: [], + } + let new_d = RoundDatum { ..d, claimed: [bettor_a] } + let tx = + mock_tx( + [script_in(d, 7000000)], + [bettor_out(bettor_a, 5000000), script_out(new_d, 2000000)], + [bettor_a], + 1100000, + 2000000, + ) + validate(Some(d), Refund { bettor: bettor_a }, mock_ref(0), tx) +} + +test refund_from_settled_round_fails() fail { + // Settled round is not refundable + let d = d_settled_up() + let new_d = RoundDatum { ..d, claimed: [bettor_a] } + let tx = + mock_tx( + [script_in(d, 17000000)], + [bettor_out(bettor_a, 10000000), script_out(new_d, 7000000)], + [bettor_a], + 1100000, + 2000000, + ) + validate(Some(d), Refund { bettor: bettor_a }, mock_ref(0), tx) +} + +test refund_already_refunded_fails() fail { + let d = + RoundDatum { + ..d_with_bets(), + close_price: Some(open_price), + status: Draw, + claimed: [bettor_a], + } + let tx = + mock_tx( + [script_in(d, 17000000)], + [bettor_out(bettor_a, 10000000), script_out(d, 7000000)], + [bettor_a], + 1100000, + 2000000, + ) + validate(Some(d), Refund { bettor: bettor_a }, mock_ref(0), tx) +} + +// C4: same pool-drain attack on Refund +test refund_overdrain_fails() fail { + let d = + RoundDatum { + ..d_with_bets(), + close_price: Some(open_price), + status: Draw, + claimed: [], + } + let stake_a = 10000000 + let new_d = RoundDatum { ..d, claimed: [bettor_a] } + let pool = 17000000 + // Script output is 0 — rest of pool stolen by attacker + let tx = + mock_tx( + [script_in(d, pool)], + [bettor_out(bettor_a, stake_a), script_out(new_d, 0)], + [bettor_a], + 1100000, + 2000000, + ) + validate(Some(d), Refund { bettor: bettor_a }, mock_ref(0), tx) +}