Browse Source

Initial daemon and frontend for taker and maker

Co-authored-by: Mariusz Klochowicz <mariusz@klochowicz.com>
frontend-backend
Daniel Karzel 3 years ago
committed by Mariusz Klochowicz
parent
commit
be7fa3e612
No known key found for this signature in database GPG Key ID: 470C865699C8D4D
  1. 21
      .github/dependabot.yml
  2. 49
      .github/workflows/ci.yml
  3. 9
      .gitignore
  4. 2306
      Cargo.lock
  5. 2
      Cargo.toml
  6. 46
      README.md
  7. 24
      daemon/Cargo.toml
  8. 27
      daemon/README.md
  9. 39
      daemon/migrations/20210903050345_create_cfd_and_offer_tables.sql
  10. 223
      daemon/sqlx-data.json
  11. 9
      daemon/src/bin/maker.rs
  12. 9
      daemon/src/bin/taker.rs
  13. 414
      daemon/src/db.rs
  14. 17
      daemon/src/db/maker.rs
  15. 17
      daemon/src/db/taker.rs
  16. 9
      daemon/src/lib.rs
  17. 31
      daemon/src/model.rs
  18. 451
      daemon/src/model/cfd.rs
  19. 287
      daemon/src/routes_maker.rs
  20. 264
      daemon/src/routes_taker.rs
  21. 41
      daemon/src/socket.rs
  22. 56
      daemon/src/state.rs
  23. 65
      daemon/src/state/maker.rs
  24. 64
      daemon/src/state/taker.rs
  25. 6
      docs/ARCHITECTURE.md
  26. 37
      docs/asset/mvp_maker_taker_db.puml
  27. 67
      docs/asset/mvp_maker_taker_messaging.puml
  28. 9
      dprint.json
  29. 5
      frontend/.gitignore
  30. 13
      frontend/index.html
  31. 8
      frontend/jest.config.js
  32. 5
      frontend/jest.setup.js
  33. 9
      frontend/jest/mocks/cssMock.js
  34. 13
      frontend/maker.html
  35. 67
      frontend/package.json
  36. 42
      frontend/src/App.css
  37. 22
      frontend/src/App.test.tsx
  38. 209
      frontend/src/Maker.tsx
  39. 161
      frontend/src/Taker.tsx
  40. 55
      frontend/src/components/CfdOffer.tsx
  41. 53
      frontend/src/components/CfdTile.tsx
  42. 34
      frontend/src/components/CurrencyInputField.tsx
  43. 20
      frontend/src/components/Hooks.tsx
  44. 17
      frontend/src/components/NavLink.tsx
  45. 52
      frontend/src/components/Types.tsx
  46. 15
      frontend/src/favicon.svg
  47. 13
      frontend/src/index.css
  48. 25
      frontend/src/index.tsx
  49. 7
      frontend/src/logo.svg
  50. 21
      frontend/src/main.tsx
  51. 21
      frontend/src/main_maker.tsx
  52. 21
      frontend/src/main_taker.tsx
  53. 41
      frontend/src/theme.tsx
  54. 1
      frontend/src/vite-env.d.ts
  55. 13
      frontend/taker.html
  56. 20
      frontend/tsconfig.json
  57. 50
      frontend/vite.config.ts
  58. 13297
      frontend/yarn.lock

21
.github/dependabot.yml

@ -0,0 +1,21 @@
# To get started with Dependabot version updates, you'll need to specify which
# package ecosystems to update and where the package manifests are located.
# Please see the documentation for all configuration options:
# https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
version: 2
updates:
- package-ecosystem: "cargo"
directory: "/"
schedule:
interval: "daily"
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "daily"
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "daily"

49
.github/workflows/ci.yml

@ -26,7 +26,17 @@ jobs:
- name: Run clippy with default features
run: cargo clippy --workspace --all-targets -- -D warnings
test_worspace:
- name: print hello world
uses: actions/setup-node@v2
with:
cache: 'yarn'
run: yarn install
run: cd frontend && yarn run eslint
cd frontend && yarn build
strategy:
matrix:
target: [ x86_64-unknown-linux-gnu, x86_64-apple-darwin ]
@ -49,5 +59,40 @@ jobs:
- uses: Swatinem/rust-cache@v1.3.0
- name: Cargo test workspace
- name: Cargo test
run: cargo test --workspace --all-features
smoke_test_daemons:
strategy:
matrix:
target: [ x86_64-unknown-linux-gnu, x86_64-apple-darwin ]
include:
- target: x86_64-unknown-linux-gnu
os: ubuntu-latest
- target: x86_64-apple-darwin
os: macos-latest
runs-on: ${{ matrix.os }}
steps:
- name: Checkout sources
uses: actions/checkout@v2
- name: Install Rust toolchain
uses: actions-rs/toolchain@v1
with:
profile: minimal
override: true
toolchain: stable
- uses: Swatinem/rust-cache@v1.3.0
- name: Build ${{ matrix.target }} release binary
run: |
cargo build --target=${{ matrix.target }} --release --bin maker
cargo build --target=${{ matrix.target }} --release --bin taker
- name: Smoke test ${{ matrix.target }} release binary
run: |
target/${{ matrix.target }}/release/maker &
target/${{ matrix.target }}/release/taker &
sleep 5s
curl --fail http://localhost:8000/alive

9
.gitignore

@ -6,3 +6,12 @@
**/*.rs.bk
/.log/
/node_modules
.yarn/*
!.yarn/patches
!.yarn/releases
!.yarn/plugins
!.yarn/sdks
!.yarn/versions
.pnp.*

2306
Cargo.lock

File diff suppressed because it is too large

2
Cargo.toml

@ -1,3 +1,3 @@
[workspace]
members = ["cfd_protocol"]
members = ["cfd_protocol", "daemon"]
resolver = "2"

46
README.md

@ -2,4 +2,48 @@
CFD trading on Bitcoin.
Coming soon.
Details coming soon.
## Starting the maker and taker daemon
The maker and taker frontend depend on the respective daemon running.
At the moment the maker daemon has to be started first:
```bash
cargo run --bin maker
```
Once the maker is started you can start the taker:
```bash
cargo run --bin taker
```
Upon startup the taker daemon will connect to the (hardcoded) maker and retrieve the current offer.
Note: The sqlite databases for maker and taker are currently created in the project root.
## Starting the maker and taker frontend
We use a single react project for hosting both the taker and the maker frontends.
To start it in development mode:
```bash
cd frontend && yarn dev
```
- To access maker: [Maker](http://localhost:3000/maker)
- To access taker: [Taker](http://localhost:3000/taker)
Bundling the web frontend and serving it from the respective daemon is yet to be configured.
At the moment you will need a browser extension to allow CORS headers like `CORS Everywhere` ([Firefox Extension](https://addons.mozilla.org/en-US/firefox/addon/cors-everywhere/)) to use the frontends.
### Linting
To run eslint, use:
```bash
cd frontend && yarn run eslint
```

24
daemon/Cargo.toml

@ -0,0 +1,24 @@
[package]
name = "daemon"
version = "0.1.0"
edition = "2018"
[dependencies]
anyhow = "1"
bdk = { version = "0.10", default-features = false }
futures = "0.3"
proptest = "1"
rocket = { git = "https://github.com/SergioBenitez/Rocket", features = ["json"] }
rocket_db_pools = { git = "https://github.com/SergioBenitez/Rocket", features = ["sqlx_sqlite"] }
rust_decimal = { version = "1.15", features = ["serde-float", "serde-arbitrary-precision"] }
rust_decimal_macros = "1.15"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
serde_with = { version = "1", features = ["macros"] }
sqlx = { version = "0.5", features = ["macros", "offline"] }
tokio = { version = "1", features = ["rt-multi-thread", "time", "macros", "sync", "process", "fs", "net"] }
tokio-util = { version = "0.6", features = ["codec"] }
uuid = { version = "0.8", features = ["serde", "v4"] }
[dev-dependencies]
tempfile = "3"

27
daemon/README.md

@ -0,0 +1,27 @@
# Maker & Taker Daemon
Daemon that enables the frontend.
The frontend is just a very thin display layer, the daemon does all the heavy lifting and calculations.
## Database
We use an `sqlite` database managed by `sqlx`.
To make `sqlx` handle the rust types correctly you have to generate `sqlx-data.json` file upon every query change.
So, if you develop on the DB and change queries you will have to update the `sqlx` rust mappings like this:
```bash
# crated temporary DB
DATABASE_URL=sqlite:tempdb cargo sqlx database create
# run the migration scripts to create the tables
DATABASE_URL=sqlite:tempdb cargo sqlx migrate run
# prepare the sqlx-data.json rust mappings
DATABASE_URL=sqlite:./daemon/tempdb cargo sqlx prepare -- --bin taker
```
Currently the database for taker and maker is the same.
The `taker` binary is used as an example to run the `prepare` command above, but it is irrelevant if you run it for taker or maker.
The `tempdb` created can be deleted, it should not be checked into the repo.
You can keep it around and just run the `prepare` statement multiple times when working on the database.

39
daemon/migrations/20210903050345_create_cfd_and_offer_tables.sql

@ -0,0 +1,39 @@
-- todo: Decimal is had to deserialize as number so we use text
create table if not exists offers
(
id integer primary key autoincrement,
uuid text unique not null,
trading_pair text not null,
position text not null,
initial_price text not null,
min_quantity text not null,
max_quantity text not null,
leverage integer not null,
liquidation_price text not null,
creation_timestamp text not null,
term text not null
);
create unique index if not exists offers_uuid
on offers (uuid);
create table if not exists cfds
(
id integer primary key autoincrement,
offer_id integer unique not null,
offer_uuid text unique not null,
quantity_usd text not null,
foreign key (offer_id) references offers (id)
);
create unique index if not exists cfd_offer_uuid
on cfds (offer_uuid);
create table if not exists cfd_states
(
id integer primary key autoincrement,
cfd_id integer not null,
state text not null,
foreign key (cfd_id) references cfds (id)
);

223
daemon/sqlx-data.json

@ -0,0 +1,223 @@
{
"db": "SQLite",
"1bd5f2355d2e9351a443ec10b2533ca9326bb2a27b9f049d60759ac5a9eba758": {
"query": "\n select\n id\n from cfds\n where offer_uuid = ?;\n ",
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": "Int64"
}
],
"parameters": {
"Right": 1
},
"nullable": [
true
]
}
},
"29bc1b2bd17146eb36e2c61acc1bed3c9b5b3014e35874c752cbd50016e99b74": {
"query": "\n insert into offers (\n uuid,\n trading_pair,\n position,\n initial_price,\n min_quantity,\n max_quantity,\n leverage,\n liquidation_price,\n creation_timestamp,\n term\n ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?);\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 10
},
"nullable": []
}
},
"4896e2d2e2c6cc03f9ae2b7de85279f295fb7e70e083e0a1a3faf3e7551650f3": {
"query": "\n select\n cfds.id as cfd_id,\n offers.uuid as offer_id,\n offers.initial_price as initial_price,\n offers.leverage as leverage,\n offers.trading_pair as trading_pair,\n offers.position as position,\n offers.liquidation_price as liquidation_price,\n cfds.quantity_usd as quantity_usd,\n cfd_states.state as state\n from cfds as cfds\n inner join offers as offers on cfds.offer_id = offers.id\n inner join cfd_states as cfd_states on cfd_states.cfd_id = cfds.id\n where cfd_states.state in (\n select\n state\n from cfd_states\n where cfd_id = cfds.id\n order by id desc\n limit 1\n )\n ",
"describe": {
"columns": [
{
"name": "cfd_id",
"ordinal": 0,
"type_info": "Int64"
},
{
"name": "offer_id",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "initial_price",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "leverage",
"ordinal": 3,
"type_info": "Int64"
},
{
"name": "trading_pair",
"ordinal": 4,
"type_info": "Text"
},
{
"name": "position",
"ordinal": 5,
"type_info": "Text"
},
{
"name": "liquidation_price",
"ordinal": 6,
"type_info": "Text"
},
{
"name": "quantity_usd",
"ordinal": 7,
"type_info": "Text"
},
{
"name": "state",
"ordinal": 8,
"type_info": "Text"
}
],
"parameters": {
"Right": 0
},
"nullable": [
false,
false,
false,
false,
false,
false,
false,
false,
false
]
}
},
"50abbb297394739ec9d85917f8c32aa8bcfa0bfe140b24e9eeda4ce8d30d4f8d": {
"query": "\n select\n state\n from cfd_states\n where cfd_id = ?\n order by id desc\n limit 1;\n ",
"describe": {
"columns": [
{
"name": "state",
"ordinal": 0,
"type_info": "Text"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false
]
}
},
"79162c94809f9fac4850236d06a76206d0914285d462c04e30ad7af222092675": {
"query": "\n insert into cfds (\n offer_id,\n offer_uuid,\n quantity_usd\n ) values (?, ?, ?);\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 3
},
"nullable": []
}
},
"a464a1feb12abadff8bfd5b2b3b7362f3846869c0702944b21737eff8f420be5": {
"query": "\n insert into cfd_states (\n cfd_id,\n state\n ) values (?, ?);\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
}
},
"d380cac37744c2169d4e0a8f13d223cbd37ca5034e461a583b18cf7566a9c5c6": {
"query": "\n select * from offers where uuid = ?;\n ",
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": "Int64"
},
{
"name": "uuid",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "trading_pair",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "position",
"ordinal": 3,
"type_info": "Text"
},
{
"name": "initial_price",
"ordinal": 4,
"type_info": "Text"
},
{
"name": "min_quantity",
"ordinal": 5,
"type_info": "Text"
},
{
"name": "max_quantity",
"ordinal": 6,
"type_info": "Text"
},
{
"name": "leverage",
"ordinal": 7,
"type_info": "Int64"
},
{
"name": "liquidation_price",
"ordinal": 8,
"type_info": "Text"
},
{
"name": "creation_timestamp",
"ordinal": 9,
"type_info": "Text"
},
{
"name": "term",
"ordinal": 10,
"type_info": "Text"
}
],
"parameters": {
"Right": 1
},
"nullable": [
true,
false,
false,
false,
false,
false,
false,
false,
false,
false,
false
]
}
},
"fac3d990211ef9e5fa8bcd6e1d6e0c8a81652b98fb11f2686affd1593fba75fd": {
"query": "\n insert into cfd_states (\n cfd_id,\n state\n ) values (?, ?);\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
}
}
}

9
daemon/src/bin/maker.rs

@ -0,0 +1,9 @@
use anyhow::Result;
use daemon::routes_maker;
#[rocket::main]
async fn main() -> Result<()> {
routes_maker::start_http().await?;
Ok(())
}

9
daemon/src/bin/taker.rs

@ -0,0 +1,9 @@
use anyhow::Result;
use daemon::routes_taker;
#[rocket::main]
async fn main() -> Result<()> {
routes_taker::start_http().await?;
Ok(())
}

414
daemon/src/db.rs

@ -0,0 +1,414 @@
use std::convert::TryInto;
use std::mem;
use anyhow::Context;
use bdk::bitcoin::Amount;
use rocket_db_pools::sqlx;
use sqlx::pool::PoolConnection;
use sqlx::{Sqlite, SqlitePool};
use crate::model::cfd::{Cfd, CfdOffer, CfdOfferId, CfdState};
use crate::model::{Leverage, Usd};
pub mod maker;
pub mod taker;
pub async fn do_run_migrations(pool: &SqlitePool) -> anyhow::Result<()> {
sqlx::migrate!("./migrations").run(pool).await?;
Ok(())
}
pub async fn insert_cfd_offer(cfd_offer: CfdOffer, pool: &SqlitePool) -> anyhow::Result<()> {
let uuid = serde_json::to_string(&cfd_offer.id).unwrap();
let trading_pair = serde_json::to_string(&cfd_offer.trading_pair).unwrap();
let position = serde_json::to_string(&cfd_offer.position).unwrap();
let initial_price = serde_json::to_string(&cfd_offer.price).unwrap();
let min_quantity = serde_json::to_string(&cfd_offer.min_quantity).unwrap();
let max_quantity = serde_json::to_string(&cfd_offer.max_quantity).unwrap();
let leverage = cfd_offer.leverage.0;
let liquidation_price = serde_json::to_string(&cfd_offer.liquidation_price).unwrap();
let creation_timestamp = serde_json::to_string(&cfd_offer.creation_timestamp).unwrap();
let term = serde_json::to_string(&cfd_offer.term).unwrap();
sqlx::query!(
r#"
insert into offers (
uuid,
trading_pair,
position,
initial_price,
min_quantity,
max_quantity,
leverage,
liquidation_price,
creation_timestamp,
term
) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?);
"#,
uuid,
trading_pair,
position,
initial_price,
min_quantity,
max_quantity,
leverage,
liquidation_price,
creation_timestamp,
term
)
.execute(pool)
.await?;
Ok(())
}
// TODO: Consider refactor the API to consistently present PoolConnections
pub async fn load_offer_by_id_from_conn(
id: CfdOfferId,
conn: &mut PoolConnection<Sqlite>,
) -> anyhow::Result<CfdOffer> {
let uuid = serde_json::to_string(&id).unwrap();
let row = sqlx::query!(
r#"
select * from offers where uuid = ?;
"#,
uuid
)
.fetch_one(conn)
.await?;
let uuid = serde_json::from_str(row.uuid.as_str()).unwrap();
let trading_pair = serde_json::from_str(row.trading_pair.as_str()).unwrap();
let position = serde_json::from_str(row.position.as_str()).unwrap();
let initial_price = serde_json::from_str(row.initial_price.as_str()).unwrap();
let min_quantity = serde_json::from_str(row.min_quantity.as_str()).unwrap();
let max_quantity = serde_json::from_str(row.max_quantity.as_str()).unwrap();
let leverage = Leverage(row.leverage.try_into().unwrap());
let liquidation_price = serde_json::from_str(row.liquidation_price.as_str()).unwrap();
let creation_timestamp = serde_json::from_str(row.creation_timestamp.as_str()).unwrap();
let term = serde_json::from_str(row.term.as_str()).unwrap();
Ok(CfdOffer {
id: uuid,
trading_pair,
position,
price: initial_price,
min_quantity,
max_quantity,
leverage,
liquidation_price,
creation_timestamp,
term,
})
}
pub async fn load_offer_by_id(id: CfdOfferId, pool: &SqlitePool) -> anyhow::Result<CfdOffer> {
let mut connection = pool.acquire().await?;
load_offer_by_id_from_conn(id, &mut connection).await
}
pub async fn insert_cfd(cfd: Cfd, pool: &SqlitePool) -> anyhow::Result<()> {
let offer_uuid = serde_json::to_string(&cfd.offer_id)?;
let offer_row = sqlx::query!(
r#"
select * from offers where uuid = ?;
"#,
offer_uuid
)
.fetch_one(pool)
.await?;
let offer_id = offer_row.id;
let quantity_usd = serde_json::to_string(&cfd.quantity_usd)?;
let cfd_state = serde_json::to_string(&cfd.state)?;
// save cfd + state in a transaction to make sure the state is only inserted if the cfd was inserted
let mut tx = pool.begin().await?;
let cfd_id = sqlx::query!(
r#"
insert into cfds (
offer_id,
offer_uuid,
quantity_usd
) values (?, ?, ?);
"#,
offer_id,
offer_uuid,
quantity_usd,
)
.execute(&mut tx)
.await?
.last_insert_rowid();
sqlx::query!(
r#"
insert into cfd_states (
cfd_id,
state
) values (?, ?);
"#,
cfd_id,
cfd_state,
)
.execute(&mut tx)
.await?;
tx.commit().await?;
Ok(())
}
pub async fn insert_new_cfd_state_by_offer_id(
offer_id: CfdOfferId,
new_state: CfdState,
pool: &SqlitePool,
) -> anyhow::Result<()> {
let cfd_id = load_cfd_id_by_offer_uuid(offer_id, pool).await?;
let latest_cfd_state_in_db = load_latest_cfd_state(cfd_id, pool)
.await
.context("loading latest state failed")?;
// make sure that the new state is different than the current one to avoid that we save the same state twice
if mem::discriminant(&latest_cfd_state_in_db) == mem::discriminant(&new_state) {
anyhow::bail!("Cannot insert new state {} for cfd with order_id {} because it currently already is in state {}", new_state, offer_id, latest_cfd_state_in_db);
}
let cfd_state = serde_json::to_string(&new_state)?;
sqlx::query!(
r#"
insert into cfd_states (
cfd_id,
state
) values (?, ?);
"#,
cfd_id,
cfd_state,
)
.execute(pool)
.await?;
Ok(())
}
pub async fn insert_new_cfd_state(cfd: Cfd, pool: &SqlitePool) -> anyhow::Result<()> {
insert_new_cfd_state_by_offer_id(cfd.offer_id, cfd.state, pool).await?;
Ok(())
}
async fn load_cfd_id_by_offer_uuid(
offer_uuid: CfdOfferId,
pool: &SqlitePool,
) -> anyhow::Result<i64> {
let offer_uuid = serde_json::to_string(&offer_uuid)?;
let cfd_id = sqlx::query!(
r#"
select
id
from cfds
where offer_uuid = ?;
"#,
offer_uuid
)
.fetch_one(pool)
.await?;
let cfd_id = cfd_id.id.context("No cfd found")?;
Ok(cfd_id)
}
async fn load_latest_cfd_state(cfd_id: i64, pool: &SqlitePool) -> anyhow::Result<CfdState> {
let latest_cfd_state = sqlx::query!(
r#"
select
state
from cfd_states
where cfd_id = ?
order by id desc
limit 1;
"#,
cfd_id
)
.fetch_one(pool)
.await?;
let latest_cfd_state_in_db: CfdState =
serde_json::from_str(dbg!(latest_cfd_state).state.as_str())?;
Ok(latest_cfd_state_in_db)
}
/// Loads all CFDs with the latest state as the CFD state
pub async fn load_all_cfds(pool: &SqlitePool) -> anyhow::Result<Vec<Cfd>> {
// TODO: Could be optimized with something like but not sure it's worth the complexity:
let rows = sqlx::query!(
r#"
select
cfds.id as cfd_id,
offers.uuid as offer_id,
offers.initial_price as initial_price,
offers.leverage as leverage,
offers.trading_pair as trading_pair,
offers.position as position,
offers.liquidation_price as liquidation_price,
cfds.quantity_usd as quantity_usd,
cfd_states.state as state
from cfds as cfds
inner join offers as offers on cfds.offer_id = offers.id
inner join cfd_states as cfd_states on cfd_states.cfd_id = cfds.id
where cfd_states.state in (
select
state
from cfd_states
where cfd_id = cfds.id
order by id desc
limit 1
)
"#
)
.fetch_all(pool)
.await?;
// TODO: We might want to separate the database model from the http model and properly map between them
let cfds = rows
.iter()
.map(|row| {
let offer_id = serde_json::from_str(row.offer_id.as_str()).unwrap();
let initial_price = serde_json::from_str(row.initial_price.as_str()).unwrap();
let leverage = Leverage(row.leverage.try_into().unwrap());
let trading_pair = serde_json::from_str(row.trading_pair.as_str()).unwrap();
let position = serde_json::from_str(row.position.as_str()).unwrap();
let liquidation_price = serde_json::from_str(row.liquidation_price.as_str()).unwrap();
let quantity = serde_json::from_str(row.quantity_usd.as_str()).unwrap();
let latest_state = serde_json::from_str(row.state.as_str()).unwrap();
Cfd {
offer_id,
initial_price,
leverage,
trading_pair,
position,
liquidation_price,
quantity_usd: quantity,
profit_btc: Amount::ZERO,
profit_usd: Usd::ZERO,
state: latest_state,
}
})
.collect();
Ok(cfds)
}
#[cfg(test)]
mod tests {
use std::fs::File;
use std::time::SystemTime;
use rust_decimal_macros::dec;
use sqlx::SqlitePool;
use tempfile::tempdir;
use crate::db::insert_cfd_offer;
use crate::model::cfd::{Cfd, CfdOffer, CfdState, CfdStateCommon};
use crate::model::Usd;
use super::*;
#[tokio::test]
async fn test_insert_and_load_offer() {
let pool = setup_test_db().await;
let cfd_offer = CfdOffer::from_default_with_price(Usd(dec!(10000))).unwrap();
insert_cfd_offer(cfd_offer.clone(), &pool).await.unwrap();
let cfd_offer_loaded = load_offer_by_id(cfd_offer.id, &pool).await.unwrap();
assert_eq!(cfd_offer, cfd_offer_loaded);
}
#[tokio::test]
async fn test_insert_and_load_cfd() {
let pool = setup_test_db().await;
let cfd_offer = CfdOffer::from_default_with_price(Usd(dec!(10000))).unwrap();
let cfd = Cfd::new(
cfd_offer.clone(),
Usd(dec!(1000)),
CfdState::PendingTakeRequest {
common: CfdStateCommon {
transition_timestamp: SystemTime::now(),
},
},
Usd(dec!(10001)),
)
.unwrap();
// the order ahs to exist in the db in order to be able to insert the cfd
insert_cfd_offer(cfd_offer, &pool).await.unwrap();
insert_cfd(cfd.clone(), &pool).await.unwrap();
let cfds_from_db = load_all_cfds(&pool).await.unwrap();
let cfd_from_db = cfds_from_db.first().unwrap().clone();
assert_eq!(cfd, cfd_from_db)
}
#[tokio::test]
async fn test_insert_new_cfd_state() {
let pool = setup_test_db().await;
let cfd_offer = CfdOffer::from_default_with_price(Usd(dec!(10000))).unwrap();
let mut cfd = Cfd::new(
cfd_offer.clone(),
Usd(dec!(1000)),
CfdState::PendingTakeRequest {
common: CfdStateCommon {
transition_timestamp: SystemTime::now(),
},
},
Usd(dec!(10001)),
)
.unwrap();
// the order ahs to exist in the db in order to be able to insert the cfd
insert_cfd_offer(cfd_offer, &pool).await.unwrap();
insert_cfd(cfd.clone(), &pool).await.unwrap();
cfd.state = CfdState::Accepted {
common: CfdStateCommon {
transition_timestamp: SystemTime::now(),
},
};
insert_new_cfd_state(cfd.clone(), &pool).await.unwrap();
let cfds_from_db = load_all_cfds(&pool).await.unwrap();
let cfd_from_db = cfds_from_db.first().unwrap().clone();
assert_eq!(cfd, cfd_from_db)
}
async fn setup_test_db() -> SqlitePool {
let temp_db = tempdir().unwrap().into_path().join("tempdb");
// file has to exist in order to connect with sqlite
File::create(temp_db.clone()).unwrap();
dbg!(&temp_db);
let pool = SqlitePool::connect(format!("sqlite:{}", temp_db.display()).as_str())
.await
.unwrap();
do_run_migrations(&pool).await.unwrap();
pool
}
}

17
daemon/src/db/maker.rs

@ -0,0 +1,17 @@
use crate::db::do_run_migrations;
use rocket::{fairing, Build, Rocket};
use rocket_db_pools::{sqlx, Database};
#[derive(Database)]
#[database("maker")]
pub struct Maker(sqlx::SqlitePool);
pub async fn run_migrations(rocket: Rocket<Build>) -> fairing::Result {
match Maker::fetch(&rocket) {
Some(db) => match do_run_migrations(&**db).await {
Ok(_) => Ok(rocket),
Err(_) => Err(rocket),
},
None => Err(rocket),
}
}

17
daemon/src/db/taker.rs

@ -0,0 +1,17 @@
use crate::db::do_run_migrations;
use rocket::{fairing, Build, Rocket};
use rocket_db_pools::{sqlx, Database};
#[derive(Database)]
#[database("taker")]
pub struct Taker(sqlx::SqlitePool);
pub async fn run_migrations(rocket: Rocket<Build>) -> fairing::Result {
match Taker::fetch(&rocket) {
Some(db) => match do_run_migrations(&**db).await {
Ok(_) => Ok(rocket),
Err(_) => Err(rocket),
},
None => Err(rocket),
}
}

9
daemon/src/lib.rs

@ -0,0 +1,9 @@
#[macro_use]
extern crate rocket;
pub mod db;
pub mod model;
pub mod routes_maker;
pub mod routes_taker;
pub mod socket;
pub mod state;

31
daemon/src/model.rs

@ -0,0 +1,31 @@
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
pub mod cfd;
#[derive(Debug, Copy, Clone, Serialize, Deserialize, PartialEq)]
pub struct Usd(pub Decimal);
impl Usd {
pub const ZERO: Self = Self(Decimal::ZERO);
}
impl Usd {
pub fn to_sat_precision(&self) -> Self {
Self(self.0.round_dp(8))
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct Leverage(pub u8);
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum TradingPair {
BtcUsd,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum Position {
Buy,
Sell,
}

451
daemon/src/model/cfd.rs

@ -0,0 +1,451 @@
use std::fmt::{Display, Formatter};
use std::time::{Duration, SystemTime};
use anyhow::{Context, Result};
use bdk::bitcoin::Amount;
use rust_decimal::Decimal;
use rust_decimal_macros::dec;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::model::{Leverage, Position, TradingPair, Usd};
#[derive(Debug, Copy, Clone, Serialize, Deserialize, PartialEq)]
pub struct CfdOfferId(Uuid);
impl Default for CfdOfferId {
fn default() -> Self {
Self(Uuid::new_v4())
}
}
impl Display for CfdOfferId {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
self.0.fmt(f)
}
}
/// A concrete offer created by a maker for a taker
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct CfdOffer {
pub id: CfdOfferId,
pub trading_pair: TradingPair,
pub position: Position,
pub price: Usd,
// TODO: [post-MVP] Representation of the contract size; at the moment the contract size is always 1 USD
pub min_quantity: Usd,
pub max_quantity: Usd,
// TODO: [post-MVP] Allow different values
pub leverage: Leverage,
pub liquidation_price: Usd,
pub creation_timestamp: SystemTime,
/// The duration that will be used for calculating the settlement timestamp
pub term: Duration,
}
impl CfdOffer {
pub fn from_default_with_price(price: Usd) -> Result<Self> {
let leverage = Leverage(5);
let maintenance_margin_rate = dec!(0.005);
let liquidation_price =
calculate_liquidation_price(&leverage, &price, &maintenance_margin_rate)?;
Ok(CfdOffer {
id: CfdOfferId::default(),
price,
min_quantity: Usd(dec!(1000)),
max_quantity: Usd(dec!(10000)),
leverage,
trading_pair: TradingPair::BtcUsd,
liquidation_price,
position: Position::Sell,
creation_timestamp: SystemTime::now(),
term: Duration::from_secs(60 * 60 * 8), // 8 hours
})
}
/// FIXME: For quick prototyping, remove when price is known
pub fn from_default_with_dummy_price() -> Result<Self> {
let price = Usd(dec!(49_000));
Self::from_default_with_price(price)
}
pub fn with_min_quantity(mut self, min_quantity: Usd) -> CfdOffer {
self.min_quantity = min_quantity;
self
}
pub fn with_max_quantity(mut self, max_quantity: Usd) -> CfdOffer {
self.max_quantity = max_quantity;
self
}
}
fn calculate_liquidation_price(
leverage: &Leverage,
price: &Usd,
maintenance_margin_rate: &Decimal,
) -> Result<Usd> {
let leverage = Decimal::from(leverage.0);
let price = price.0;
// liquidation price calc in isolated margin mode
// currently based on: https://help.bybit.com/hc/en-us/articles/360039261334-How-to-calculate-Liquidation-Price-Inverse-Contract-
let liquidation_price = price
.checked_mul(leverage)
.context("multiplication error")?
.checked_div(
leverage
.checked_add(Decimal::ONE)
.context("addition error")?
.checked_sub(
maintenance_margin_rate
.checked_mul(leverage)
.context("multiplication error")?,
)
.context("subtraction error")?,
)
.context("division error")?;
Ok(Usd(liquidation_price))
}
/// The taker POSTs this to create a Cfd
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CfdTakeRequest {
pub offer_id: CfdOfferId,
pub quantity: Usd,
}
/// The maker POSTs this to create a new CfdOffer
// TODO: Use Rocket form?
#[derive(Debug, Clone, Deserialize)]
pub struct CfdNewOfferRequest {
pub price: Usd,
// TODO: [post-MVP] Representation of the contract size; at the moment the contract size is always 1 USD
pub min_quantity: Usd,
pub max_quantity: Usd,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum Error {
// TODO
ConnectionLost,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CfdStateError {
last_successful_state: CfdState,
error: Error,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
pub struct CfdStateCommon {
pub transition_timestamp: SystemTime,
}
// Note: De-/Serialize with type tag to make handling on UI easier
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
#[serde(tag = "type", content = "payload")]
pub enum CfdState {
/// The taker has requested to take a CFD, but has not messaged the maker yet.
///
/// This state only applies to the taker.
TakeRequested { common: CfdStateCommon },
/// The taker sent an open request to the maker to open the CFD but don't have a response yet.
///
/// This state applies to taker and maker.
/// Initial state for the maker.
PendingTakeRequest { common: CfdStateCommon },
/// The maker has accepted the CFD take request, but the contract is not set up on chain yet.
///
/// This state applies to taker and maker.
Accepted { common: CfdStateCommon },
/// The maker rejected the CFD take request.
///
/// This state applies to taker and maker.
Rejected { common: CfdStateCommon },
/// State used during contract setup.
///
/// This state applies to taker and maker.
/// All contract setup messages between taker and maker are expected to be sent in on scope.
ContractSetup { common: CfdStateCommon },
/// The CFD contract is set up on chain.
///
/// This state applies to taker and maker.
Open {
common: CfdStateCommon,
settlement_timestamp: SystemTime,
},
/// Requested close the position, but we have not passed that on to the blockchain yet.
///
/// This state applies to taker and maker.
CloseRequested { common: CfdStateCommon },
/// The close transaction (CET) was published on the Bitcoin blockchain but we don't have a confirmation yet.
///
/// This state applies to taker and maker.
PendingClose { common: CfdStateCommon },
/// The close transaction is confirmed with at least one block.
///
/// This state applies to taker and maker.
Closed { common: CfdStateCommon },
/// Error state
///
/// This state applies to taker and maker.
Error { common: CfdStateCommon },
}
impl CfdState {
fn get_common(&self) -> CfdStateCommon {
let common = match self {
CfdState::TakeRequested { common } => common,
CfdState::PendingTakeRequest { common } => common,
CfdState::Accepted { common } => common,
CfdState::Rejected { common } => common,
CfdState::ContractSetup { common } => common,
CfdState::Open { common, .. } => common,
CfdState::CloseRequested { common } => common,
CfdState::PendingClose { common } => common,
CfdState::Closed { common } => common,
CfdState::Error { common } => common,
};
*common
}
pub fn get_transition_timestamp(&self) -> SystemTime {
self.get_common().transition_timestamp
}
}
impl Display for CfdState {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
CfdState::TakeRequested { .. } => {
write!(f, "Take Requested")
}
CfdState::PendingTakeRequest { .. } => {
write!(f, "Pending Take Request")
}
CfdState::Accepted { .. } => {
write!(f, "Accepted")
}
CfdState::Rejected { .. } => {
write!(f, "Rejected")
}
CfdState::ContractSetup { .. } => {
write!(f, "Contract Setup")
}
CfdState::Open { .. } => {
write!(f, "Open")
}
CfdState::CloseRequested { .. } => {
write!(f, "Close Requested")
}
CfdState::PendingClose { .. } => {
write!(f, "Pending Close")
}
CfdState::Closed { .. } => {
write!(f, "Closed")
}
CfdState::Error { .. } => {
write!(f, "Error")
}
}
}
}
/// Represents a cfd (including state)
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct Cfd {
pub offer_id: CfdOfferId,
pub initial_price: Usd,
pub leverage: Leverage,
pub trading_pair: TradingPair,
pub position: Position,
pub liquidation_price: Usd,
pub quantity_usd: Usd,
#[serde(with = "::bdk::bitcoin::util::amount::serde::as_btc")]
pub profit_btc: Amount,
pub profit_usd: Usd,
pub state: CfdState,
}
impl Cfd {
pub fn new(
cfd_offer: CfdOffer,
quantity: Usd,
state: CfdState,
current_price: Usd,
) -> Result<Self> {
let (profit_btc, profit_usd) =
calculate_profit(cfd_offer.price, current_price, dec!(0.005), Usd(dec!(0.1)))?;
Ok(Cfd {
offer_id: cfd_offer.id,
initial_price: cfd_offer.price,
leverage: cfd_offer.leverage,
trading_pair: cfd_offer.trading_pair,
position: cfd_offer.position,
liquidation_price: cfd_offer.liquidation_price,
quantity_usd: quantity,
// initially the profit is zero
profit_btc,
profit_usd,
state,
})
}
}
fn calculate_profit(
_intial_price: Usd,
_current_price: Usd,
_interest_per_day: Decimal,
_fee: Usd,
) -> Result<(Amount, Usd)> {
// TODO: profit calculation
Ok((Amount::ZERO, Usd::ZERO))
}
#[cfg(test)]
mod tests {
use std::time::UNIX_EPOCH;
use rust_decimal_macros::dec;
use super::*;
#[test]
fn given_default_values_then_expected_liquidation_price() {
let leverage = Leverage(5);
let price = Usd(dec!(49000));
let maintenance_margin_rate = dec!(0.005);
let liquidation_price =
calculate_liquidation_price(&leverage, &price, &maintenance_margin_rate).unwrap();
assert_eq!(liquidation_price, Usd(dec!(41004.184100418410041841004184)));
}
#[test]
fn serialize_cfd_state_snapshot() {
// This test is to prevent us from breaking the cfd_state API used by the UI and database!
// We serialize the state into the database, so changes to the enum result in breaking program version changes.
let fixed_timestamp = UNIX_EPOCH;
let cfd_state = CfdState::TakeRequested {
common: CfdStateCommon {
transition_timestamp: fixed_timestamp,
},
};
let json = serde_json::to_string(&cfd_state).unwrap();
assert_eq!(
json,
r#"{"type":"TakeRequested","payload":{"common":{"transition_timestamp":{"secs_since_epoch":0,"nanos_since_epoch":0}}}}"#
);
let cfd_state = CfdState::PendingTakeRequest {
common: CfdStateCommon {
transition_timestamp: fixed_timestamp,
},
};
let json = serde_json::to_string(&cfd_state).unwrap();
assert_eq!(
json,
r#"{"type":"PendingTakeRequest","payload":{"common":{"transition_timestamp":{"secs_since_epoch":0,"nanos_since_epoch":0}}}}"#
);
let cfd_state = CfdState::Accepted {
common: CfdStateCommon {
transition_timestamp: fixed_timestamp,
},
};
let json = serde_json::to_string(&cfd_state).unwrap();
assert_eq!(
json,
r#"{"type":"Accepted","payload":{"common":{"transition_timestamp":{"secs_since_epoch":0,"nanos_since_epoch":0}}}}"#
);
let cfd_state = CfdState::ContractSetup {
common: CfdStateCommon {
transition_timestamp: fixed_timestamp,
},
};
let json = serde_json::to_string(&cfd_state).unwrap();
assert_eq!(
json,
r#"{"type":"ContractSetup","payload":{"common":{"transition_timestamp":{"secs_since_epoch":0,"nanos_since_epoch":0}}}}"#
);
let cfd_state = CfdState::Open {
common: CfdStateCommon {
transition_timestamp: fixed_timestamp,
},
settlement_timestamp: fixed_timestamp,
};
let json = serde_json::to_string(&cfd_state).unwrap();
assert_eq!(
json,
r#"{"type":"Open","payload":{"common":{"transition_timestamp":{"secs_since_epoch":0,"nanos_since_epoch":0}},"settlement_timestamp":{"secs_since_epoch":0,"nanos_since_epoch":0}}}"#
);
let cfd_state = CfdState::CloseRequested {
common: CfdStateCommon {
transition_timestamp: fixed_timestamp,
},
};
let json = serde_json::to_string(&cfd_state).unwrap();
assert_eq!(
json,
r#"{"type":"CloseRequested","payload":{"common":{"transition_timestamp":{"secs_since_epoch":0,"nanos_since_epoch":0}}}}"#
);
let cfd_state = CfdState::PendingClose {
common: CfdStateCommon {
transition_timestamp: fixed_timestamp,
},
};
let json = serde_json::to_string(&cfd_state).unwrap();
assert_eq!(
json,
r#"{"type":"PendingClose","payload":{"common":{"transition_timestamp":{"secs_since_epoch":0,"nanos_since_epoch":0}}}}"#
);
let cfd_state = CfdState::Closed {
common: CfdStateCommon {
transition_timestamp: fixed_timestamp,
},
};
let json = serde_json::to_string(&cfd_state).unwrap();
assert_eq!(
json,
r#"{"type":"Closed","payload":{"common":{"transition_timestamp":{"secs_since_epoch":0,"nanos_since_epoch":0}}}}"#
);
let cfd_state = CfdState::Error {
common: CfdStateCommon {
transition_timestamp: fixed_timestamp,
},
};
let json = serde_json::to_string(&cfd_state).unwrap();
assert_eq!(
json,
r#"{"type":"Error","payload":{"common":{"transition_timestamp":{"secs_since_epoch":0,"nanos_since_epoch":0}}}}"#
);
}
}

287
daemon/src/routes_maker.rs

@ -0,0 +1,287 @@
use std::time::SystemTime;
use anyhow::Result;
use bdk::bitcoin::Amount;
use futures::stream::SelectAll;
use futures::StreamExt;
use rocket::fairing::AdHoc;
use rocket::figment::util::map;
use rocket::figment::value::{Map, Value};
use rocket::response::status;
use rocket::response::stream::{Event, EventStream};
use rocket::serde::json::Json;
use rocket::State;
use rocket_db_pools::{Connection, Database};
use rust_decimal_macros::dec;
use tokio::select;
use tokio::sync::{mpsc, watch};
use tokio_util::codec::{FramedRead, LengthDelimitedCodec};
use uuid::Uuid;
use crate::db;
use crate::model::cfd::{
Cfd, CfdNewOfferRequest, CfdOffer, CfdState, CfdStateCommon, CfdTakeRequest,
};
use crate::model::Usd;
use crate::socket::*;
use crate::state::maker::{maker_do_something, Command};
trait ToSseEvent {
fn to_sse_event(&self) -> Event;
}
impl ToSseEvent for Vec<Cfd> {
fn to_sse_event(&self) -> Event {
Event::json(self).event("cfds")
}
}
impl ToSseEvent for Option<CfdOffer> {
fn to_sse_event(&self) -> Event {
Event::json(self).event("offer")
}
}
impl ToSseEvent for Amount {
fn to_sse_event(&self) -> Event {
Event::json(&self.as_btc()).event("balance")
}
}
#[get("/maker-feed")]
async fn maker_feed(
rx_cfds: &State<watch::Receiver<Vec<Cfd>>>,
rx_offer: &State<watch::Receiver<Option<CfdOffer>>>,
rx_balance: &State<watch::Receiver<Amount>>,
) -> EventStream![] {
let mut rx_cfds = rx_cfds.inner().clone();
let mut rx_offer = rx_offer.inner().clone();
let mut rx_balance = rx_balance.inner().clone();
EventStream! {
let balance = rx_balance.borrow().clone();
yield balance.to_sse_event();
let offer = rx_offer.borrow().clone();
yield offer.to_sse_event();
let cfds = rx_cfds.borrow().clone();
yield cfds.to_sse_event();
loop{
select! {
Ok(()) = rx_balance.changed() => {
let balance = rx_balance.borrow().clone();
yield balance.to_sse_event();
},
Ok(()) = rx_offer.changed() => {
let offer = rx_offer.borrow().clone();
yield offer.to_sse_event();
}
Ok(()) = rx_cfds.changed() => {
let cfds = rx_cfds.borrow().clone();
yield cfds.to_sse_event();
}
}
}
}
}
#[post("/offer/sell", data = "<cfd_confirm_offer_request>")]
async fn post_sell_offer(
cfd_confirm_offer_request: Json<CfdNewOfferRequest>,
queue: &State<mpsc::Sender<CfdOffer>>,
) -> Result<status::Accepted<()>, status::BadRequest<String>> {
let offer = CfdOffer::from_default_with_price(cfd_confirm_offer_request.price)
.map_err(|e| status::BadRequest(Some(e.to_string())))?
.with_min_quantity(cfd_confirm_offer_request.min_quantity)
.with_max_quantity(cfd_confirm_offer_request.max_quantity);
let _res = queue
.send(offer)
.await
.map_err(|_| status::BadRequest(Some("internal server error".to_string())))?;
Ok(status::Accepted(None))
}
// TODO: Shall we use a simpler struct for verification? AFAICT quantity is not
// needed, no need to send the whole CFD either as the other fields can be generated from the offer
#[post("/offer/confirm", data = "<cfd_confirm_offer_request>")]
async fn post_confirm_offer(
cfd_confirm_offer_request: Json<CfdTakeRequest>,
queue: &State<mpsc::Sender<CfdOffer>>,
mut conn: Connection<db::maker::Maker>,
) -> Result<status::Accepted<()>, status::BadRequest<String>> {
dbg!(&cfd_confirm_offer_request);
let offer = db::load_offer_by_id_from_conn(cfd_confirm_offer_request.offer_id, &mut conn)
.await
.map_err(|e| status::BadRequest(Some(e.to_string())))?;
let _res = queue
.send(offer)
.await
.map_err(|_| status::BadRequest(Some("internal server error".to_string())))?;
Ok(status::Accepted(None))
}
#[get("/alive")]
fn get_health_check() {}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
struct RetrieveCurrentOffer;
pub async fn start_http() -> Result<()> {
let (cfd_feed_sender, cfd_feed_receiver) = watch::channel::<Vec<Cfd>>(vec![]);
let (offer_feed_sender, offer_feed_receiver) = watch::channel::<Option<CfdOffer>>(None);
let (_balance_feed_sender, balance_feed_receiver) = watch::channel::<Amount>(Amount::ONE_BTC);
let (new_cfd_offer_sender, mut new_cfd_offer_receiver) = mpsc::channel::<CfdOffer>(1024);
let (db_command_sender, db_command_receiver) = mpsc::channel::<Command>(1024);
// init the CFD feed, this will be picked up by the receiver managed by rocket once started
db_command_sender
.send(Command::RefreshCfdFeed)
.await
.unwrap();
let listener = tokio::net::TcpListener::bind("0.0.0.0:9999").await?;
let local_addr = listener.local_addr().unwrap();
println!("Listening on {}", local_addr);
tokio::spawn({
let db_command_sender = db_command_sender.clone();
let offer_feed_receiver = offer_feed_receiver.clone();
async move {
let mut read_connections = SelectAll::new();
let mut write_connections = std::collections::HashMap::new();
loop {
select! {
Ok((socket, remote_addr)) = listener.accept() => {
println!("Connected to {}", remote_addr);
let uuid = Uuid::new_v4();
let (read, write) = socket.into_split();
let messages = FramedRead::new(read, LengthDelimitedCodec::new())
.map(|result| {
let message = serde_json::from_slice::<Message>(&result?)?;
anyhow::Result::<_>::Ok(message)
})
.map(move |message_result| (uuid, remote_addr, message_result));
read_connections.push(messages);
let sender = spawn_sender(write);
if let Some(latest_offer) = &*offer_feed_receiver.borrow() {
sender.send(Message::CurrentOffer(Some(latest_offer.clone()))).expect("Could not communicate with taker");
}
write_connections.insert(uuid, sender);
},
Some(cfd_offer) = new_cfd_offer_receiver.recv() => {
db_command_sender.send(Command::SaveOffer(cfd_offer.clone())).await.unwrap();
offer_feed_sender.send(Some(cfd_offer.clone())).unwrap();
for sender in write_connections.values() {
sender.send(Message::CurrentOffer(Some(cfd_offer.clone()))).expect("Could not communicate with taker");
}
},
Some((uuid, _peer, message)) = read_connections.next() => {
match message {
Ok(Message::TakeOffer(cfd_take_request)) => {
println!("Received a CFD offer take request {:?}", cfd_take_request);
if offer_feed_receiver.borrow().as_ref().is_none() {
eprintln!("Maker has no current offer anymore - can't handle a take request");
return;
}
let current_offer = offer_feed_receiver.borrow().as_ref().unwrap().clone();
assert_eq!(current_offer.id, cfd_take_request.offer_id, "You can only confirm the current offer");
let cfd = Cfd::new(current_offer, cfd_take_request.quantity, CfdState::PendingTakeRequest{
common: CfdStateCommon {
transition_timestamp: SystemTime::now()
},
},
Usd(dec!(10001))).unwrap();
db_command_sender.send(Command::SaveCfd(cfd.clone())).await.unwrap();
// FIXME: Use a button on the CFD tile to
// confirm instead of auto-confirmation
write_connections.get(&uuid).expect("taker to still online")
.send(Message::ConfirmTakeOffer(cfd_take_request.offer_id)).unwrap();
db_command_sender.send(Command::SaveNewCfdStateByOfferId(cfd.offer_id, CfdState::Accepted { common: CfdStateCommon { transition_timestamp: SystemTime::now()}})).await.unwrap();
// Remove the current offer as it got accepted
offer_feed_sender.send(None).unwrap();
for sender in write_connections.values() {
sender.send(Message::CurrentOffer(None)).expect("Could not communicate with taker");
}
},
Ok(Message::StartContractSetup(offer_id)) => {
db_command_sender.send(Command::SaveNewCfdStateByOfferId(offer_id, CfdState::ContractSetup { common: CfdStateCommon { transition_timestamp: SystemTime::now()}})).await.unwrap();
db_command_sender.send(Command::RefreshCfdFeed).await.unwrap();
}
Ok(Message::CurrentOffer(_)) => {
panic!("Maker should not receive current offer");
},
Ok(Message::ConfirmTakeOffer(_)) => {
panic!("Maker should not receive offer confirmations");
},
Err(error) => {
eprintln!("Error in reading message: {}", error );
}
}
}
}
}
}
});
let db: Map<_, Value> = map! {
"url" => "./maker.sqlite".into(),
};
let figment = rocket::Config::figment()
.merge(("databases", map!["maker" => db]))
.merge(("port", 8001));
rocket::custom(figment)
.manage(cfd_feed_receiver)
.manage(offer_feed_receiver)
.manage(new_cfd_offer_sender)
.manage(balance_feed_receiver)
.manage(db_command_sender)
.attach(db::maker::Maker::init())
.attach(AdHoc::try_on_ignite(
"SQL migrations",
db::maker::run_migrations,
))
.attach(AdHoc::try_on_ignite("send command to the db", |rocket| {
maker_do_something(rocket, db_command_receiver, cfd_feed_sender)
}))
.mount(
"/",
routes![
maker_feed,
post_sell_offer,
post_confirm_offer,
get_health_check
],
)
.launch()
.await?;
Ok(())
}

264
daemon/src/routes_taker.rs

@ -0,0 +1,264 @@
use std::time::SystemTime;
use anyhow::Result;
use bdk::bitcoin::Amount;
use futures::StreamExt;
use rocket::fairing::AdHoc;
use rocket::figment::util::map;
use rocket::figment::value::{Map, Value};
use rocket::response::stream::{Event, EventStream};
use rocket::serde::json::Json;
use rocket::State;
use rocket_db_pools::{Connection, Database};
use rust_decimal::Decimal;
use tokio::select;
use tokio::sync::{mpsc, watch};
use tokio_util::codec::{FramedRead, LengthDelimitedCodec};
use crate::db;
use crate::model::cfd::{Cfd, CfdOffer, CfdState, CfdStateCommon, CfdTakeRequest};
use crate::model::{Position, Usd};
use crate::socket::*;
use crate::state::taker::{hey_db_do_something, Command};
trait ToSseEvent {
fn to_sse_event(&self) -> Event;
}
impl ToSseEvent for Vec<Cfd> {
fn to_sse_event(&self) -> Event {
Event::json(self).event("cfds")
}
}
impl ToSseEvent for Option<CfdOffer> {
fn to_sse_event(&self) -> Event {
Event::json(self).event("offer")
}
}
impl ToSseEvent for Amount {
fn to_sse_event(&self) -> Event {
Event::json(&self.as_btc()).event("balance")
}
}
#[get("/feed")]
async fn feed(
rx_cfds: &State<watch::Receiver<Vec<Cfd>>>,
rx_offer: &State<watch::Receiver<Option<CfdOffer>>>,
rx_balance: &State<watch::Receiver<Amount>>,
) -> EventStream![] {
let mut rx_cfds = rx_cfds.inner().clone();
let mut rx_offer = rx_offer.inner().clone();
let mut rx_balance = rx_balance.inner().clone();
EventStream! {
let balance = rx_balance.borrow().clone();
yield balance.to_sse_event();
let offer = rx_offer.borrow().clone();
yield offer.to_sse_event();
let cfds = rx_cfds.borrow().clone();
yield cfds.to_sse_event();
loop{
select! {
Ok(()) = rx_balance.changed() => {
let balance = rx_balance.borrow().clone();
yield balance.to_sse_event();
},
Ok(()) = rx_offer.changed() => {
let offer = rx_offer.borrow().clone();
yield offer.to_sse_event();
}
Ok(()) = rx_cfds.changed() => {
let cfds = rx_cfds.borrow().clone();
yield cfds.to_sse_event();
}
}
}
}
}
#[post("/cfd", data = "<cfd_take_request>")]
async fn post_cfd(
cfd_take_request: Json<CfdTakeRequest>,
cfd_sender: &State<mpsc::Sender<Cfd>>,
db_command_sender: &State<mpsc::Sender<Command>>,
mut conn: Connection<db::taker::Taker>,
) {
let current_offer = db::load_offer_by_id_from_conn(cfd_take_request.offer_id, &mut conn)
.await
.unwrap();
println!("Accepting current offer: {:?}", &current_offer);
let cfd = Cfd {
offer_id: current_offer.id,
initial_price: current_offer.price,
leverage: current_offer.leverage,
trading_pair: current_offer.trading_pair,
liquidation_price: current_offer.liquidation_price,
position: Position::Buy,
quantity_usd: cfd_take_request.quantity,
profit_btc: Amount::ZERO,
profit_usd: Usd(Decimal::ZERO),
state: CfdState::TakeRequested {
common: CfdStateCommon {
transition_timestamp: SystemTime::now(),
},
},
};
db_command_sender
.send(Command::SaveCfd(cfd.clone()))
.await
.unwrap();
// TODO: remove unwrap
cfd_sender.send(cfd).await.unwrap();
}
#[get("/alive")]
fn get_health_check() {}
pub async fn start_http() -> Result<()> {
let (cfd_feed_sender, cfd_feed_receiver) = watch::channel::<Vec<Cfd>>(vec![]);
let (offer_feed_sender, offer_feed_receiver) = watch::channel::<Option<CfdOffer>>(None);
let (_balance_feed_sender, balance_feed_receiver) = watch::channel::<Amount>(Amount::ONE_BTC);
let (take_cfd_sender, mut take_cfd_receiver) = mpsc::channel::<Cfd>(1024);
let (db_command_sender, db_command_receiver) = mpsc::channel::<Command>(1024);
// init the CFD feed, this will be picked up by the receiver managed by rocket once started
db_command_sender
.send(Command::RefreshCfdFeed)
.await
.unwrap();
let socket = tokio::net::TcpSocket::new_v4().unwrap();
let connection = socket
.connect("0.0.0.0:9999".parse().unwrap())
.await
.expect("Maker should be online first");
let (read, write) = connection.into_split();
tokio::spawn({
let db_command_sender = db_command_sender.clone();
let mut cfd_feed_receiver = cfd_feed_receiver.clone();
async move {
let frame_read = FramedRead::new(read, LengthDelimitedCodec::new());
let mut messages = frame_read.map(|result| {
let message = serde_json::from_slice::<Message>(&result?)?;
anyhow::Result::<_>::Ok(message)
});
let sender = spawn_sender(write);
loop {
select! {
Some(cfd) = take_cfd_receiver.recv() => {
sender.send(Message::TakeOffer(CfdTakeRequest { offer_id : cfd.offer_id, quantity : cfd.quantity_usd})).unwrap();
let cfd_with_new_state = Cfd {
state : CfdState::PendingTakeRequest {
common: CfdStateCommon {
transition_timestamp: SystemTime::now(),
},
},
..cfd
};
db_command_sender.send(Command::SaveNewCfdState(cfd_with_new_state)).await.unwrap();
},
Some(message) = messages.next() => {
match message {
Ok(Message::TakeOffer(_)) => {
eprintln!("Taker should not receive take requests");
},
Ok(Message::CurrentOffer(offer)) => {
if let Some(offer) = &offer {
println!("Received new offer from the maker: {:?}", offer );
db_command_sender.send(Command::SaveOffer(offer.clone())).await.unwrap();
}
else {
println!("Maker does not have an offer anymore");
}
offer_feed_sender.send(offer).unwrap();
},
Ok(Message::StartContractSetup(_)) => {
eprintln!("Taker should not receive start contract setup message as the taker sends it");
}
Ok(Message::ConfirmTakeOffer(offer_id)) => {
println!("The maker has accepted your take request for offer: {:?}", offer_id );
let new_state : CfdState=
CfdState::Accepted {
common: CfdStateCommon {
transition_timestamp: SystemTime::now(),
},
};
db_command_sender.send(Command::SaveNewCfdStateByOfferId(offer_id, new_state)).await.unwrap();
},
Err(error) => {
eprintln!("Error in reading message: {}", error );
}
}
},
Ok(()) = cfd_feed_receiver.changed() => {
let cfds = cfd_feed_receiver.borrow().clone();
let to_be_accepted = cfds.into_iter().filter(|by| matches!(by.state, CfdState::Accepted {..})).collect::<Vec<Cfd>>();
for mut cfd in to_be_accepted {
let new_state : CfdState=
CfdState::ContractSetup {
common: CfdStateCommon {
transition_timestamp: SystemTime::now(),
},
};
cfd.state = new_state;
db_command_sender.send(Command::SaveNewCfdState(cfd)).await.unwrap();
// TODO: Send message to Maker for contract setup (and transition Maker into that state upon receiving the message)
}
}
}
}
}
});
let db: Map<_, Value> = map! {
"url" => "./taker.sqlite".into(),
};
let figment = rocket::Config::figment().merge(("databases", map!["taker" => db]));
rocket::custom(figment)
.manage(offer_feed_receiver)
.manage(cfd_feed_receiver)
.manage(take_cfd_sender)
.manage(balance_feed_receiver)
.manage(db_command_sender)
.attach(db::taker::Taker::init())
.attach(AdHoc::try_on_ignite(
"SQL migrations",
db::taker::run_migrations,
))
.attach(AdHoc::try_on_ignite("send command to the db", |rocket| {
hey_db_do_something(rocket, db_command_receiver, cfd_feed_sender)
}))
.mount("/", routes![feed, post_cfd, get_health_check])
.launch()
.await?;
Ok(())
}

41
daemon/src/socket.rs

@ -0,0 +1,41 @@
use serde::{Deserialize, Serialize};
use tokio::net::tcp::OwnedWriteHalf;
use crate::model::cfd::{CfdOffer, CfdOfferId, CfdTakeRequest};
use futures::SinkExt;
use tokio::sync::mpsc::UnboundedSender;
use tokio_util::codec::{FramedWrite, LengthDelimitedCodec};
// TODO: Implement messages used for confirming CFD offer between maker and taker
#[derive(Debug, Serialize, Deserialize)]
#[serde(tag = "type", content = "payload")]
pub enum Message {
CurrentOffer(Option<CfdOffer>),
TakeOffer(CfdTakeRequest),
// TODO: Needs RejectOffer as well
ConfirmTakeOffer(CfdOfferId),
// TODO: Currently the taker starts, can already send some stuff for signing over in the first message.
StartContractSetup(CfdOfferId),
}
pub fn spawn_sender(write: OwnedWriteHalf) -> UnboundedSender<Message> {
let (sender, mut receiver) = tokio::sync::mpsc::unbounded_channel::<Message>();
tokio::spawn(async move {
let mut framed_write = FramedWrite::new(write, LengthDelimitedCodec::new());
while let Some(message) = receiver.recv().await {
match framed_write
.send(serde_json::to_vec(&message).unwrap().into())
.await
{
Ok(_) => {}
Err(_) => {
eprintln!("TCP connection error");
break;
}
}
}
});
sender
}

56
daemon/src/state.rs

@ -0,0 +1,56 @@
use crate::db::{
insert_cfd, insert_cfd_offer, insert_new_cfd_state, insert_new_cfd_state_by_offer_id,
load_all_cfds, load_offer_by_id,
};
use crate::model::cfd::{Cfd, CfdOffer, CfdOfferId, CfdState};
use tokio::sync::watch;
pub mod maker;
pub mod taker;
#[derive(Debug)]
pub enum Command {
SaveOffer(CfdOffer),
SaveCfd(Cfd),
SaveNewCfdState(Cfd),
SaveNewCfdStateByOfferId(CfdOfferId, CfdState),
RefreshCfdFeed,
}
pub async fn handle_command(
db: &sqlx::SqlitePool,
command: Command,
cfd_feed_sender: &watch::Sender<Vec<Cfd>>,
) -> anyhow::Result<()> {
println!("Handle command: {:?}", command);
match command {
Command::SaveOffer(cfd_offer) => {
// Only save offer when it wasn't already saved (e.g. taker
// can see the same "latest" offer when it comes back online)
if let Ok(offer) = load_offer_by_id(cfd_offer.id, db).await {
println!("Offer with id {:?} already stored in the db.", offer.id);
} else {
insert_cfd_offer(cfd_offer, db).await?
}
}
Command::SaveCfd(cfd) => {
insert_cfd(cfd, db).await?;
cfd_feed_sender.send(load_all_cfds(db).await?)?;
}
Command::SaveNewCfdState(cfd) => {
insert_new_cfd_state(cfd, db).await?;
cfd_feed_sender.send(load_all_cfds(db).await?)?;
}
Command::SaveNewCfdStateByOfferId(offer_id, state) => {
insert_new_cfd_state_by_offer_id(offer_id, state, db).await?;
cfd_feed_sender.send(load_all_cfds(db).await?)?;
}
Command::RefreshCfdFeed => {
cfd_feed_sender.send(load_all_cfds(db).await?)?;
}
}
Ok(())
}

65
daemon/src/state/maker.rs

@ -0,0 +1,65 @@
use crate::model::cfd::{Cfd, CfdOffer, CfdOfferId, CfdState};
use crate::state;
use crate::state::handle_command;
use rocket::{fairing, Build, Rocket};
use rocket_db_pools::Database;
use std::convert::{TryFrom, TryInto};
use tokio::sync::{mpsc, watch};
#[derive(Debug, Clone)]
pub enum Command {
SaveOffer(CfdOffer),
SaveCfd(Cfd),
SaveNewCfdState(Cfd),
SaveNewCfdStateByOfferId(CfdOfferId, CfdState),
/// All other commands that are interacting with Cfds perform a refresh
/// automatically - as such, RefreshCfdFeed should be used only on init
RefreshCfdFeed,
}
pub struct NotSharedCommand;
impl TryFrom<Command> for state::Command {
type Error = NotSharedCommand;
fn try_from(value: Command) -> Result<Self, Self::Error> {
let command = match value {
Command::SaveOffer(offer) => state::Command::SaveOffer(offer),
Command::SaveCfd(cfd) => state::Command::SaveCfd(cfd),
Command::SaveNewCfdState(cfd) => state::Command::SaveNewCfdState(cfd),
Command::SaveNewCfdStateByOfferId(uuid, cfd_state) => {
state::Command::SaveNewCfdStateByOfferId(uuid, cfd_state)
}
Command::RefreshCfdFeed => state::Command::RefreshCfdFeed,
};
Ok(command)
}
}
// TODO write more rocket wrapper functions to insert into db and create long-running tasks + channels handling the insert
pub async fn maker_do_something(
rocket: Rocket<Build>,
mut db_command_receiver: mpsc::Receiver<Command>,
cfd_feed_sender: watch::Sender<Vec<Cfd>>,
) -> fairing::Result {
let db = match crate::db::maker::Maker::fetch(&rocket) {
Some(db) => (**db).clone(),
None => return Err(rocket),
};
tokio::spawn(async move {
while let Some(command) = db_command_receiver.recv().await {
match command.clone().try_into() {
Ok(shared_command) => {
handle_command(&db, shared_command, &cfd_feed_sender)
.await
.unwrap();
}
Err(_) => unreachable!("currently there are only shared commands"),
}
}
});
Ok(rocket)
}

64
daemon/src/state/taker.rs

@ -0,0 +1,64 @@
use crate::model::cfd::{Cfd, CfdOffer, CfdOfferId, CfdState};
use crate::state;
use crate::state::handle_command;
use rocket::{fairing, Build, Rocket};
use rocket_db_pools::Database;
use std::convert::{TryFrom, TryInto};
use tokio::sync::{mpsc, watch};
#[derive(Debug, Clone)]
pub enum Command {
SaveOffer(CfdOffer),
SaveCfd(Cfd),
SaveNewCfdState(Cfd),
SaveNewCfdStateByOfferId(CfdOfferId, CfdState),
/// All other commands that are interacting with Cfds perform a refresh
/// automatically - as such, RefreshCfdFeed should be used only on init
RefreshCfdFeed,
}
pub struct CommandError;
impl TryFrom<Command> for state::Command {
type Error = CommandError;
fn try_from(value: Command) -> Result<Self, Self::Error> {
let command = match value {
Command::SaveOffer(offer) => state::Command::SaveOffer(offer),
Command::SaveCfd(cfd) => state::Command::SaveCfd(cfd),
Command::SaveNewCfdState(cfd) => state::Command::SaveNewCfdState(cfd),
Command::SaveNewCfdStateByOfferId(uuid, cfd_state) => {
state::Command::SaveNewCfdStateByOfferId(uuid, cfd_state)
}
Command::RefreshCfdFeed => state::Command::RefreshCfdFeed,
};
Ok(command)
}
}
pub async fn hey_db_do_something(
rocket: Rocket<Build>,
mut db_command_receiver: mpsc::Receiver<Command>,
cfd_feed_sender: watch::Sender<Vec<Cfd>>,
) -> fairing::Result {
let db = match crate::db::taker::Taker::fetch(&rocket) {
Some(db) => (**db).clone(),
None => return Err(rocket),
};
tokio::spawn(async move {
while let Some(command) = db_command_receiver.recv().await {
match command.clone().try_into() {
Ok(shared_command) => {
handle_command(&db, shared_command, &cfd_feed_sender)
.await
.unwrap();
}
Err(_) => unreachable!("currently there are only shared commands"),
}
}
});
Ok(rocket)
}

6
docs/ARCHITECTURE.md

@ -38,9 +38,9 @@ User interaction MUST NOT directly change the state displayed to the user.
Instead, we maintain a cycle of:
1. User interaction triggers POST request to backend
1. Backend state changes
1. State change emits update on SSE feed
1. Event on SSE triggers re-render in application
2. Backend state changes
3. State change emits update on SSE feed
4. Event on SSE triggers re-render in application
As a result of this invariant, we can be sure that any state change is accurately reflected in the frontend, regardless of how it was triggered.
It also makes the frontend very thin and therefore more predictable.

37
docs/asset/mvp_maker_taker_db.puml

@ -0,0 +1,37 @@
@startuml
' hide the spot
hide circle
' avoid problems with angled crows feet
skinparam linetype ortho
entity "offers" as offer {
*id : number <<PK>> <<generated>>
--
...
}
entity "cfds" as cfd {
*id : number <<PK>> <<generated>>
--
*offer_id : text <<FK>>
--
quantity_usd: long
creation_timestamp: Date
}
entity "cfd_states" as cfd_states {
*id : number <<PK>> <<generated>>
--
state: blob
}
note left: state de-/serialized \nfrom rust state enum \nthis is not backwards\ncompatible, but that's \nOK for the MVP
offer ||--|| cfd
cfd ||--|{ cfd_states
@enduml

67
docs/asset/mvp_maker_taker_messaging.puml

@ -0,0 +1,67 @@
@startuml
actor "Buyer=Taker \n[frontend]" as Buyer
participant "Buyer \n[daemon]" as BuyerApp
participant "Buyer Offer Feed \n[in daemon]" as BuyerOfferFeed
participant "Buyer CFD Feed \n[in daemon]" as BuyerCfdFeed
participant "Seller CFD Feed \n[in daemon]" as SellerCfdFeed
participant "Seller Offer Feed \n[in daemon]" as SellerOfferFeed
participant "Seller \n[daemon]" as SellerApp
actor "Seller=Maker \n[frontend]" as Seller
participant Oracle as Oracle
note over Seller : currently static offer in the frontend
Seller -> SellerOfferFeed: Subscribe
note over Seller: The seller should see the current active offer \nInitially there is none (until one POSTed)
Seller -> SellerApp: POST sell offer
group Oracle stuff?
SellerApp -> Oracle: Attestation for sell offer
Oracle --> SellerApp: Attestation pubkey
end group
SellerApp -> SellerApp: Store current offer
SellerApp -> SellerOfferFeed: Push offer
SellerOfferFeed --> Seller: offer [Untaken]
Buyer -> BuyerApp: Start daemon & UI
BuyerApp -> BuyerOfferFeed: Subscribe
BuyerApp -> BuyerCfdFeed: Subscribe
BuyerApp -> SellerApp: Open TCP (socket) connection
SellerApp -> SellerApp: New connection
SellerApp -> BuyerApp: {TCP} Current offer
note over SellerOfferFeed : Assumption: Current offer \nalways available for new subscriptions
BuyerApp -> BuyerOfferFeed: push offer
BuyerOfferFeed --> Buyer: offer
Buyer -> Buyer: Click BUY
Buyer -> BuyerApp: POST cfd_take_request
BuyerApp -> BuyerApp: Create cfd [TakeRequested]
note over BuyerApp: Must include offer_id
BuyerApp -> BuyerCfdFeed: Push cfd
BuyerCfdFeed --> Buyer: cfd [TakeRequested]
BuyerApp -> SellerApp: {TCP} cfd_take_request (offer_id, quantity)
SellerApp -> SellerApp: Create cfd [TakeRequested]
SellerApp -> SellerCfdFeed: cfd [TakeRequested]
SellerCfdFeed --> Seller: cfd [TakeRequested]
Seller -> Seller: Accept cfd
Seller -> SellerApp: POST cfd [Accepted]
SellerApp -> BuyerApp: {TCP} cfd [Accepted]
SellerApp -> SellerCfdFeed: cfd [Accepted]
SellerCfdFeed --> Seller: cfd [Accepted]
BuyerApp -> BuyerCfdFeed: cfd [Accepted]
BuyerCfdFeed --> Buyer: cfd [Accepted]
ref over BuyerApp, SellerApp: {TCP} protocol setup messaging & contract publication on Bitcoin\n-> cfd in state [ContractSetup]
SellerApp -> SellerCfdFeed: cfd [Open]
SellerCfdFeed --> Seller: cfd [Open]
BuyerApp -> BuyerCfdFeed: cfd [Open]
BuyerCfdFeed --> Buyer: cfd [Open]
@enduml

9
dprint.json

@ -1,14 +1,21 @@
{
"$schema": "https://dprint.dev/schemas/v0.json",
"projectType": "openSource",
"incremental": true,
"rustfmt": {
"imports_granularity": "module"
},
"includes": ["**/*.{md,rs,toml}"],
"excludes": ["**/target"],
"excludes": ["**/target",
"**/sqlx-data.json",
"frontend/dist",
"**/node_modules"
],
"plugins": [
"https://plugins.dprint.dev/markdown-0.9.2.wasm",
"https://plugins.dprint.dev/rustfmt-0.4.0.exe-plugin@c6bb223ef6e5e87580177f6461a0ab0554ac9ea6b54f78ea7ae8bf63b14f5bc2",
"https://plugins.dprint.dev/toml-0.4.0.wasm"
"https://plugins.dprint.dev/typescript-0.35.0.wasm",
"https://plugins.dprint.dev/json-0.7.2.wasm"
]
}

5
frontend/.gitignore

@ -0,0 +1,5 @@
node_modules
.DS_Store
dist
dist-ssr
*.local

13
frontend/index.html

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/src/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Hermes Main page</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="./src/index.tsx"></script>
</body>
</html>

8
frontend/jest.config.js

@ -0,0 +1,8 @@
module.exports = {
preset: "vite-jest",
setupFilesAfterEnv: ["<rootDir>/jest.setup.js"],
testMatch: [
"<rootDir>/src/**/*.test.{js,jsx,ts,tsx}",
],
testEnvironment: "jest-environment-jsdom",
};

5
frontend/jest.setup.js

@ -0,0 +1,5 @@
// jest-dom adds custom jest matchers for asserting on DOM nodes.
// allows you to do things like:
// expect(element).toHaveTextContent(/react/i)
// learn more: https://github.com/testing-library/jest-dom
require("@testing-library/jest-dom");

9
frontend/jest/mocks/cssMock.js

@ -0,0 +1,9 @@
module.exports = {
process() {
return "module.exports = {};";
},
getCacheKey() {
// The output is always the same.
return "cssTransform";
},
};

13
frontend/maker.html

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/src/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Hermes Maker</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="./src/main_maker.tsx"></script>
</body>
</html>

67
frontend/package.json

@ -0,0 +1,67 @@
{
"name": "frontend",
"version": "0.0.0",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"serve": "vite preview",
"test": "vite-jest"
},
"dependencies": {
"@chakra-ui/icons": "^1.0.15",
"@chakra-ui/react": "^1.6.7",
"@chakra-ui/system": "^1.7.3",
"@emotion/react": "^11.4.1",
"@emotion/styled": "^11.3.0",
"@testing-library/dom": "^8.2.0",
"@testing-library/jest-dom": "^5.11.4",
"@testing-library/react": "^11.1.0",
"@testing-library/user-event": "^12.1.10",
"@types/jest": "^26.0.15",
"@types/node": "^12.0.0",
"@types/react": "^17.0.0",
"@types/react-dom": "^17.0.0",
"@types/react-table": "^7.7.2",
"@typescript-eslint/eslint-plugin": "^4.30.0",
"@typescript-eslint/parser": "^4.30.0",
"axios": "^0.21.1",
"babel-eslint": "^10.1.0",
"eslint": "^7.32.0",
"eslint-config-react-app": "^6.0.0",
"eslint-plugin-flowtype": "^5.9.2",
"eslint-plugin-import": "^2.24.2",
"eslint-plugin-jest": "^24.4.0",
"eslint-plugin-jsx-a11y": "^6.4.1",
"eslint-plugin-react": "^7.25.1",
"eslint-plugin-react-hooks": "^4.2.0",
"framer-motion": "^4",
"history": ">=5",
"jest": "^27",
"prettier": "^2.3.2",
"react": "^17.0.2",
"react-async": "^10.0.1",
"react-dom": "^17.0.2",
"react-refresh": "^0.10.0",
"react-router-dom": "=6.0.0-beta.2",
"react-scripts": "^4.0.3",
"react-sse-hooks": "^1.0.5",
"react-table": "^7.7.0",
"typescript": "^4.4.2",
"vite-jest": "^0.0.3",
"web-vitals": "^1.0.1"
},
"devDependencies": {
"@types/eslint": "^7",
"@types/react": "^17.0.0",
"@types/react-dom": "^17.0.0",
"@vitejs/plugin-react-refresh": "^1.3.1",
"typescript": "^4.4.2",
"vite": "^2.5.2"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
}
}

42
frontend/src/App.css

@ -0,0 +1,42 @@
.App {
text-align: center;
}
.App-logo {
height: 40vmin;
pointer-events: none;
}
@media (prefers-reduced-motion: no-preference) {
.App-logo {
animation: App-logo-spin infinite 20s linear;
}
}
.App-header {
background-color: #282c34;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-size: calc(10px + 2vmin);
color: white;
}
.App-link {
color: #61dafb;
}
@keyframes App-logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
button {
font-size: calc(10px + 2vmin);
}

22
frontend/src/App.test.tsx

@ -0,0 +1,22 @@
import { ChakraProvider } from "@chakra-ui/react";
import React from "react";
import ReactDOM from "react-dom";
import { BrowserRouter, Route, Routes } from "react-router-dom";
import Maker from "./Maker";
import theme from "./theme";
it("renders without crashing", () => {
const div = document.createElement("div");
ReactDOM.render(
<React.StrictMode>
<ChakraProvider theme={theme}>
<BrowserRouter>
<Routes>
<Route path="/maker/*" element={<Maker />} />
</Routes>
</BrowserRouter>
</ChakraProvider>
</React.StrictMode>,
div,
);
});

209
frontend/src/Maker.tsx

@ -0,0 +1,209 @@
import {
Box,
Button,
Center,
Divider,
Flex,
HStack,
SimpleGrid,
StackDivider,
Text,
useToast,
VStack,
} from "@chakra-ui/react";
import axios from "axios";
import React, { useState } from "react";
import { useAsync } from "react-async";
import { Route, Routes } from "react-router-dom";
import { useEventSource } from "react-sse-hooks";
import "./App.css";
import CfdOffer from "./components/CfdOffer";
import CfdTile from "./components/CfdTile";
import CurrencyInputField from "./components/CurrencyInputField";
import useLatestEvent from "./components/Hooks";
import NavLink from "./components/NavLink";
import { Cfd, Offer } from "./components/Types";
/* TODO: Change from localhost:8001 */
const BASE_URL = "http://localhost:8001";
interface CfdSellOfferPayload {
price: number;
min_quantity: number;
max_quantity: number;
}
async function postCfdSellOfferRequest(payload: CfdSellOfferPayload) {
let res = await axios.post(BASE_URL + `/offer/sell`, JSON.stringify(payload));
if (!res.status.toString().startsWith("2")) {
console.log("Status: " + res.status + ", " + res.statusText);
throw new Error("failed to publish new offer");
}
}
export default function App() {
let source = useEventSource({ source: BASE_URL + "/maker-feed" });
const cfds = useLatestEvent<Cfd[]>(source, "cfds");
const offer = useLatestEvent<Offer>(source, "offer");
console.log(cfds);
const balance = useLatestEvent<number>(source, "balance");
const toast = useToast();
let [minQuantity, setMinQuantity] = useState<string>("100");
let [maxQuantity, setMaxQuantity] = useState<string>("1000");
let [offerPrice, setOfferPrice] = useState<string>("10000");
const format = (val: any) => `$` + val;
const parse = (val: any) => val.replace(/^\$/, "");
let { run: makeNewCfdSellOffer, isLoading: isCreatingNewCfdOffer } = useAsync({
deferFn: async ([payload]: any[]) => {
try {
await postCfdSellOfferRequest(payload as CfdSellOfferPayload);
} catch (e) {
const description = typeof e === "string" ? e : JSON.stringify(e);
toast({
title: "Error",
description,
status: "error",
duration: 9000,
isClosable: true,
});
}
},
});
return (
<Center marginTop={50}>
<HStack>
<Box marginRight={5}>
<VStack align={"top"}>
<NavLink text={"trade"} path={"trade"} />
<NavLink text={"wallet"} path={"wallet"} />
<NavLink text={"settings"} path={"settings"} />
</VStack>
</Box>
<Box width={1200} height="100%">
<Routes>
<Route
path="trade"
element={<Flex direction={"row"} height={"100%"}>
<Flex direction={"row"} width={"100%"}>
<VStack
spacing={5}
shadow={"md"}
padding={5}
width={"100%"}
divider={<StackDivider borderColor="gray.200" />}
>
<Box width={"100%"} overflow={"scroll"}>
<SimpleGrid columns={2} spacing={10}>
{cfds && cfds.map((cfd, index) =>
<CfdTile
key={"cfd_" + index}
index={index}
cfd={cfd}
/>
)}
</SimpleGrid>
</Box>
</VStack>
</Flex>
<Flex width={"50%"} marginLeft={5}>
<VStack spacing={5} shadow={"md"} padding={5} align={"stretch"}>
<HStack>
<Text align={"left"}>Your balance:</Text>
<Text>{balance}</Text>
</HStack>
<HStack>
<Text align={"left"}>Current Price:</Text>
<Text>{49000}</Text>
</HStack>
<HStack>
<Text>Min Quantity:</Text>
<CurrencyInputField
onChange={(valueString: string) => setMinQuantity(parse(valueString))}
value={format(minQuantity)}
/>
</HStack>
<HStack>
<Text>Min Quantity:</Text>
<CurrencyInputField
onChange={(valueString: string) => setMaxQuantity(parse(valueString))}
value={format(maxQuantity)}
/>
</HStack>
<HStack>
<Text>Offer Price:</Text>
</HStack>
<CurrencyInputField
onChange={(valueString: string) => setOfferPrice(parse(valueString))}
value={format(offerPrice)}
/>
<Text>Leverage:</Text>
<Flex justifyContent={"space-between"}>
<Button disabled={true}>x1</Button>
<Button disabled={true}>x2</Button>
<Button colorScheme="blue" variant="solid">x{5}</Button>
</Flex>
<VStack>
<Center><Text>Maker UI</Text></Center>
<Button
disabled={isCreatingNewCfdOffer}
variant={"solid"}
colorScheme={"blue"}
onClick={() => {
let payload: CfdSellOfferPayload = {
price: Number.parseFloat(offerPrice),
min_quantity: Number.parseFloat(minQuantity),
max_quantity: Number.parseFloat(maxQuantity),
};
makeNewCfdSellOffer(payload);
}}
>
{offer ? "Update Sell Offer" : "Create Sell Offer"}
</Button>
<Divider />
<Box width={"100%"} overflow={"scroll"}>
<Box>
{offer
&& <CfdOffer
offer={offer}
/>}
</Box>
</Box>
</VStack>
</VStack>
</Flex>
</Flex>}
>
</Route>
<Route
path="wallet"
element={<Center height={"100%"} shadow={"md"}>
<Box>
<Text>Wallet</Text>
</Box>
</Center>}
>
</Route>
<Route
path="settings"
element={<Center height={"100%"} shadow={"md"}>
<Box>
<Text>Settings</Text>
</Box>
</Center>}
>
</Route>
</Routes>
</Box>
</HStack>
</Center>
);
}

161
frontend/src/Taker.tsx

@ -0,0 +1,161 @@
import { Box, Button, Center, Flex, HStack, SimpleGrid, StackDivider, Text, useToast, VStack } from "@chakra-ui/react";
import axios from "axios";
import React, { useState } from "react";
import { useAsync } from "react-async";
import { Route, Routes } from "react-router-dom";
import { useEventSource } from "react-sse-hooks";
import "./App.css";
import CfdTile from "./components/CfdTile";
import CurrencyInputField from "./components/CurrencyInputField";
import useLatestEvent from "./components/Hooks";
import NavLink from "./components/NavLink";
import { Cfd, Offer } from "./components/Types";
/* TODO: Change from localhost:8000 */
const BASE_URL = "http://localhost:8000";
interface CfdTakeRequestPayload {
offer_id: string;
quantity: number;
}
async function postCfdTakeRequest(payload: CfdTakeRequestPayload) {
let res = await axios.post(BASE_URL + `/cfd`, JSON.stringify(payload));
if (!res.status.toString().startsWith("2")) {
throw new Error("failed to create new CFD take request: " + res.status + ", " + res.statusText);
}
}
export default function App() {
let source = useEventSource({ source: BASE_URL + "/feed" });
const cfds = useLatestEvent<Cfd[]>(source, "cfds");
const offer = useLatestEvent<Offer>(source, "offer");
const balance = useLatestEvent<number>(source, "balance");
const toast = useToast();
let [quantity, setQuantity] = useState<string>("10000");
const format = (val: any) => `$` + val;
const parse = (val: any) => val.replace(/^\$/, "");
let { run: makeNewTakeRequest, isLoading: isCreatingNewTakeRequest } = useAsync({
deferFn: async ([payload]: any[]) => {
try {
await postCfdTakeRequest(payload as CfdTakeRequestPayload);
} catch (e) {
const description = typeof e === "string" ? e : JSON.stringify(e);
toast({
title: "Error",
description,
status: "error",
duration: 9000,
isClosable: true,
});
}
},
});
return (
<Center marginTop={50}>
<HStack>
<Box marginRight={5}>
<VStack align={"top"}>
<NavLink text={"trade"} path={"trade"} />
<NavLink text={"wallet"} path={"wallet"} />
<NavLink text={"settings"} path={"settings"} />
</VStack>
</Box>
<Box width={1200} height="100%" maxHeight={800}>
<Routes>
<Route path="trade">
<Flex direction={"row"} height={"100%"}>
<Flex direction={"row"} width={"100%"}>
<VStack
spacing={5}
shadow={"md"}
padding={5}
width={"100%"}
divider={<StackDivider borderColor="gray.200" />}
>
<Box width={"100%"} overflow={"scroll"}>
<SimpleGrid columns={2} spacing={10}>
{cfds && cfds.map((cfd, index) =>
<CfdTile
key={"cfd_" + index}
index={index}
cfd={cfd}
/>
)}
</SimpleGrid>
</Box>
</VStack>
</Flex>
<Flex width={"50%"} marginLeft={5}>
<VStack spacing={5} shadow={"md"} padding={5} align={"stretch"}>
<HStack>
<Text align={"left"}>Your balance:</Text>
<Text>{balance}</Text>
</HStack>
<HStack>
{/*TODO: Do we need this? does it make sense to only display the price from the offer?*/}
<Text align={"left"}>Current Price (Kraken):</Text>
<Text>tbd</Text>
</HStack>
<HStack>
<Text align={"left"}>Offer Price:</Text>
<Text>{offer?.price}</Text>
</HStack>
<HStack>
<Text>Quantity:</Text>
<CurrencyInputField
onChange={(valueString: string) => setQuantity(parse(valueString))}
value={format(quantity)}
/>
</HStack>
<Text>Leverage:</Text>
{/* TODO: consider button group */}
<Flex justifyContent={"space-between"}>
<Button disabled={true}>x1</Button>
<Button disabled={true}>x2</Button>
<Button colorScheme="blue" variant="solid">x{offer?.leverage}</Button>
</Flex>
{<Button
disabled={isCreatingNewTakeRequest || !offer}
variant={"solid"}
colorScheme={"blue"}
onClick={() => {
let payload: CfdTakeRequestPayload = {
offer_id: offer!.id,
quantity: Number.parseFloat(quantity),
};
makeNewTakeRequest(payload);
}}
>
BUY
</Button>}
</VStack>
</Flex>
</Flex>
</Route>
<Route path="wallet">
<Center height={"100%"} shadow={"md"}>
<Box>
<Text>Wallet</Text>
</Box>
</Center>
</Route>
<Route path="settings">
<Center height={"100%"} shadow={"md"}>
<Box>
<Text>Settings</Text>
</Box>
</Center>
</Route>
</Routes>
</Box>
</HStack>
</Center>
);
}

55
frontend/src/components/CfdOffer.tsx

@ -0,0 +1,55 @@
import { Box, SimpleGrid, Text, VStack } from "@chakra-ui/react";
import React from "react";
import { Offer } from "./Types";
interface CfdOfferProps {
offer: Offer;
}
function CfdOffer(
{
offer,
}: CfdOfferProps,
) {
return (
<Box borderRadius={"md"} borderColor={"blue.800"} borderWidth={2} bg={"gray.50"}>
<VStack>
<Box bg="blue.800" w="100%">
<Text padding={2} color={"white"} fontWeight={"bold"}>Current CFD Sell Offer</Text>
</Box>
<SimpleGrid padding={5} columns={2} spacing={5}>
<Text>ID</Text>
<Text
overflow="hidden"
textOverflow="ellipsis"
whiteSpace="nowrap"
_hover={{ overflow: "visible" }}
>
{offer.id}
</Text>
<Text>Trading Pair</Text>
<Text>{offer.trading_pair}</Text>
<Text>Price</Text>
<Text>{offer.price}</Text>
<Text>Min Quantity</Text>
<Text>{offer.min_quantity}</Text>
<Text>Max Quantity</Text>
<Text>{offer.max_quantity}</Text>
<Text>Leverage</Text>
<Text>{offer.leverage}</Text>
<Text>Liquidation Price</Text>
<Text
overflow="hidden"
textOverflow="ellipsis"
whiteSpace="nowrap"
_hover={{ overflow: "visible" }}
>
{offer.liquidation_price}
</Text>
</SimpleGrid>
</VStack>
</Box>
);
}
export default CfdOffer;

53
frontend/src/components/CfdTile.tsx

@ -0,0 +1,53 @@
import { Box, Button, SimpleGrid, Text, VStack } from "@chakra-ui/react";
import React from "react";
import { Cfd } from "./Types";
interface CfdTileProps {
index: number;
cfd: Cfd;
}
export default function CfdTile(
{
index,
cfd,
}: CfdTileProps,
) {
return (
<Box borderRadius={"md"} borderColor={"blue.800"} borderWidth={2} bg={"gray.50"}>
<VStack>
<Box bg="blue.800" w="100%">
<Text padding={2} color={"white"} fontWeight={"bold"}>CFD #{index}</Text>
</Box>
<SimpleGrid padding={5} columns={2} spacing={5}>
<Text>Trading Pair</Text>
<Text>{cfd.trading_pair}</Text>
<Text>Position</Text>
<Text>{cfd.position}</Text>
<Text>Amount</Text>
<Text>{cfd.quantity_usd}</Text>
<Text>Liquidation Price</Text>
<Text
overflow="hidden"
textOverflow="ellipsis"
whiteSpace="nowrap"
_hover={{ overflow: "visible" }}
>
{cfd.liquidation_price}
</Text>
<Text>Profit</Text>
<Text>{cfd.profit_usd}</Text>
<Text>Open since</Text>
{/* TODO: Format date in a more compact way */}
<Text>
{(new Date(cfd.state.payload.common.transition_timestamp.secs_since_epoch * 1000).toString())}
</Text>
<Text>Status</Text>
<Text>{cfd.state.type}</Text>
</SimpleGrid>
{cfd.state.type === "Open"
&& <Box paddingBottom={5}><Button colorScheme="blue" variant="solid">Close</Button></Box>}
</VStack>
</Box>
);
}

34
frontend/src/components/CurrencyInputField.tsx

@ -0,0 +1,34 @@
import {
NumberDecrementStepper,
NumberIncrementStepper,
NumberInput,
NumberInputField,
NumberInputStepper,
} from "@chakra-ui/react";
import { StringOrNumber } from "@chakra-ui/utils";
import React from "react";
interface CurrencyInputFieldProps {
onChange: any;
value: StringOrNumber | undefined;
}
export default function CurrencyInputField(
{
onChange,
value,
}: CurrencyInputFieldProps,
) {
return (
<NumberInput
onChange={onChange}
value={value}
>
<NumberInputField />
<NumberInputStepper>
<NumberIncrementStepper />
<NumberDecrementStepper />
</NumberInputStepper>
</NumberInput>
);
}

20
frontend/src/components/Hooks.tsx

@ -0,0 +1,20 @@
import React, { useState } from "react";
import { useEventSource, useEventSourceListener } from "react-sse-hooks";
export default function useLatestEvent<T>(source: EventSource, event_name: string): T | null {
const [state, setState] = useState<T | null>(null);
useEventSourceListener<T | null>(
{
source: source,
startOnInit: true,
event: {
name: event_name,
listener: ({ data }) => setState(data),
},
},
[source],
);
return state;
}

17
frontend/src/components/NavLink.tsx

@ -0,0 +1,17 @@
import { Button } from "@chakra-ui/react";
import React from "react";
import { Link as RouteLink, useMatch } from "react-router-dom";
type NavLinkProps = { text: string; path: string };
export default function NavLink({ text, path }: NavLinkProps) {
const match = useMatch(path);
return (
<RouteLink to={path}>
<Button width="100px" colorScheme="blue" variant={match?.path ? "solid" : "outline"}>
{text}
</Button>
</RouteLink>
);
}

52
frontend/src/components/Types.tsx

@ -0,0 +1,52 @@
export interface RustDuration {
secs: number;
nanos: number;
}
export interface RustTimestamp {
secs_since_epoch: number;
nanos_since_epoch: number;
}
export interface Offer {
id: string;
trading_pair: string;
position: string;
price: number;
min_quantity: number;
max_quantity: number;
leverage: number;
liquidation_price: number;
creation_timestamp: RustTimestamp;
term: RustDuration;
}
export interface CfdStateCommon {
transition_timestamp: RustTimestamp;
}
export interface CfdStatePayload {
common: CfdStateCommon;
settlement_timestamp?: RustTimestamp; // only in state Open
}
export interface CfdState {
type: string;
payload: CfdStatePayload;
}
export interface Cfd {
offer_id: string;
initial_price: number;
leverage: number;
trading_pair: string;
position: string;
liquidation_price: number;
quantity_usd: number;
profit_btc: number;
profit_usd: number;
state: CfdState;
}

15
frontend/src/favicon.svg

@ -0,0 +1,15 @@
<svg width="410" height="404" viewBox="0 0 410 404" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M399.641 59.5246L215.643 388.545C211.844 395.338 202.084 395.378 198.228 388.618L10.5817 59.5563C6.38087 52.1896 12.6802 43.2665 21.0281 44.7586L205.223 77.6824C206.398 77.8924 207.601 77.8904 208.776 77.6763L389.119 44.8058C397.439 43.2894 403.768 52.1434 399.641 59.5246Z" fill="url(#paint0_linear)"/>
<path d="M292.965 1.5744L156.801 28.2552C154.563 28.6937 152.906 30.5903 152.771 32.8664L144.395 174.33C144.198 177.662 147.258 180.248 150.51 179.498L188.42 170.749C191.967 169.931 195.172 173.055 194.443 176.622L183.18 231.775C182.422 235.487 185.907 238.661 189.532 237.56L212.947 230.446C216.577 229.344 220.065 232.527 219.297 236.242L201.398 322.875C200.278 328.294 207.486 331.249 210.492 326.603L212.5 323.5L323.454 102.072C325.312 98.3645 322.108 94.137 318.036 94.9228L279.014 102.454C275.347 103.161 272.227 99.746 273.262 96.1583L298.731 7.86689C299.767 4.27314 296.636 0.855181 292.965 1.5744Z" fill="url(#paint1_linear)"/>
<defs>
<linearGradient id="paint0_linear" x1="6.00017" y1="32.9999" x2="235" y2="344" gradientUnits="userSpaceOnUse">
<stop stop-color="#41D1FF"/>
<stop offset="1" stop-color="#BD34FE"/>
</linearGradient>
<linearGradient id="paint1_linear" x1="194.651" y1="8.81818" x2="236.076" y2="292.989" gradientUnits="userSpaceOnUse">
<stop stop-color="#FFEA83"/>
<stop offset="0.0833333" stop-color="#FFDD35"/>
<stop offset="1" stop-color="#FFA800"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

13
frontend/src/index.css

@ -0,0 +1,13 @@
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}

25
frontend/src/index.tsx

@ -0,0 +1,25 @@
import { ChakraProvider } from "@chakra-ui/react";
import React from "react";
import ReactDOM from "react-dom";
import { BrowserRouter, Route, Routes } from "react-router-dom";
import { EventSourceProvider } from "react-sse-hooks";
import "./index.css";
import Maker from "./Maker";
import Taker from "./Taker";
import theme from "./theme";
ReactDOM.render(
<React.StrictMode>
<ChakraProvider theme={theme}>
<EventSourceProvider>
<BrowserRouter>
<Routes>
<Route path="/maker/*" element={<Maker />} />
<Route path="/taker/*" element={<Taker />} />
</Routes>
</BrowserRouter>
</EventSourceProvider>
</ChakraProvider>
</React.StrictMode>,
document.getElementById("root"),
);

7
frontend/src/logo.svg

@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 841.9 595.3">
<g fill="#61DAFB">
<path d="M666.3 296.5c0-32.5-40.7-63.3-103.1-82.4 14.4-63.6 8-114.2-20.2-130.4-6.5-3.8-14.1-5.6-22.4-5.6v22.3c4.6 0 8.3.9 11.4 2.6 13.6 7.8 19.5 37.5 14.9 75.7-1.1 9.4-2.9 19.3-5.1 29.4-19.6-4.8-41-8.5-63.5-10.9-13.5-18.5-27.5-35.3-41.6-50 32.6-30.3 63.2-46.9 84-46.9V78c-27.5 0-63.5 19.6-99.9 53.6-36.4-33.8-72.4-53.2-99.9-53.2v22.3c20.7 0 51.4 16.5 84 46.6-14 14.7-28 31.4-41.3 49.9-22.6 2.4-44 6.1-63.6 11-2.3-10-4-19.7-5.2-29-4.7-38.2 1.1-67.9 14.6-75.8 3-1.8 6.9-2.6 11.5-2.6V78.5c-8.4 0-16 1.8-22.6 5.6-28.1 16.2-34.4 66.7-19.9 130.1-62.2 19.2-102.7 49.9-102.7 82.3 0 32.5 40.7 63.3 103.1 82.4-14.4 63.6-8 114.2 20.2 130.4 6.5 3.8 14.1 5.6 22.5 5.6 27.5 0 63.5-19.6 99.9-53.6 36.4 33.8 72.4 53.2 99.9 53.2 8.4 0 16-1.8 22.6-5.6 28.1-16.2 34.4-66.7 19.9-130.1 62-19.1 102.5-49.9 102.5-82.3zm-130.2-66.7c-3.7 12.9-8.3 26.2-13.5 39.5-4.1-8-8.4-16-13.1-24-4.6-8-9.5-15.8-14.4-23.4 14.2 2.1 27.9 4.7 41 7.9zm-45.8 106.5c-7.8 13.5-15.8 26.3-24.1 38.2-14.9 1.3-30 2-45.2 2-15.1 0-30.2-.7-45-1.9-8.3-11.9-16.4-24.6-24.2-38-7.6-13.1-14.5-26.4-20.8-39.8 6.2-13.4 13.2-26.8 20.7-39.9 7.8-13.5 15.8-26.3 24.1-38.2 14.9-1.3 30-2 45.2-2 15.1 0 30.2.7 45 1.9 8.3 11.9 16.4 24.6 24.2 38 7.6 13.1 14.5 26.4 20.8 39.8-6.3 13.4-13.2 26.8-20.7 39.9zm32.3-13c5.4 13.4 10 26.8 13.8 39.8-13.1 3.2-26.9 5.9-41.2 8 4.9-7.7 9.8-15.6 14.4-23.7 4.6-8 8.9-16.1 13-24.1zM421.2 430c-9.3-9.6-18.6-20.3-27.8-32 9 .4 18.2.7 27.5.7 9.4 0 18.7-.2 27.8-.7-9 11.7-18.3 22.4-27.5 32zm-74.4-58.9c-14.2-2.1-27.9-4.7-41-7.9 3.7-12.9 8.3-26.2 13.5-39.5 4.1 8 8.4 16 13.1 24 4.7 8 9.5 15.8 14.4 23.4zM420.7 163c9.3 9.6 18.6 20.3 27.8 32-9-.4-18.2-.7-27.5-.7-9.4 0-18.7.2-27.8.7 9-11.7 18.3-22.4 27.5-32zm-74 58.9c-4.9 7.7-9.8 15.6-14.4 23.7-4.6 8-8.9 16-13 24-5.4-13.4-10-26.8-13.8-39.8 13.1-3.1 26.9-5.8 41.2-7.9zm-90.5 125.2c-35.4-15.1-58.3-34.9-58.3-50.6 0-15.7 22.9-35.6 58.3-50.6 8.6-3.7 18-7 27.7-10.1 5.7 19.6 13.2 40 22.5 60.9-9.2 20.8-16.6 41.1-22.2 60.6-9.9-3.1-19.3-6.5-28-10.2zM310 490c-13.6-7.8-19.5-37.5-14.9-75.7 1.1-9.4 2.9-19.3 5.1-29.4 19.6 4.8 41 8.5 63.5 10.9 13.5 18.5 27.5 35.3 41.6 50-32.6 30.3-63.2 46.9-84 46.9-4.5-.1-8.3-1-11.3-2.7zm237.2-76.2c4.7 38.2-1.1 67.9-14.6 75.8-3 1.8-6.9 2.6-11.5 2.6-20.7 0-51.4-16.5-84-46.6 14-14.7 28-31.4 41.3-49.9 22.6-2.4 44-6.1 63.6-11 2.3 10.1 4.1 19.8 5.2 29.1zm38.5-66.7c-8.6 3.7-18 7-27.7 10.1-5.7-19.6-13.2-40-22.5-60.9 9.2-20.8 16.6-41.1 22.2-60.6 9.9 3.1 19.3 6.5 28.1 10.2 35.4 15.1 58.3 34.9 58.3 50.6-.1 15.7-23 35.6-58.4 50.6zM320.8 78.4z"/>
<circle cx="420.9" cy="296.5" r="45.7"/>
<path d="M520.5 78.1z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

21
frontend/src/main.tsx

@ -0,0 +1,21 @@
import { ChakraProvider } from "@chakra-ui/react";
import React from "react";
import ReactDOM from "react-dom";
import { BrowserRouter } from "react-router-dom";
import { EventSourceProvider } from "react-sse-hooks";
import "./index.css";
import App from "./Maker";
import theme from "./theme";
ReactDOM.render(
<React.StrictMode>
<ChakraProvider theme={theme}>
<EventSourceProvider>
<BrowserRouter>
<App />
</BrowserRouter>
</EventSourceProvider>
</ChakraProvider>
</React.StrictMode>,
document.getElementById("root"),
);

21
frontend/src/main_maker.tsx

@ -0,0 +1,21 @@
import { ChakraProvider } from "@chakra-ui/react";
import React from "react";
import ReactDOM from "react-dom";
import { BrowserRouter } from "react-router-dom";
import { EventSourceProvider } from "react-sse-hooks";
import "./index.css";
import Maker from "./Maker";
import theme from "./theme";
ReactDOM.render(
<React.StrictMode>
<ChakraProvider theme={theme}>
<EventSourceProvider>
<BrowserRouter>
<Maker />
</BrowserRouter>
</EventSourceProvider>
</ChakraProvider>
</React.StrictMode>,
document.getElementById("root"),
);

21
frontend/src/main_taker.tsx

@ -0,0 +1,21 @@
import { ChakraProvider } from "@chakra-ui/react";
import React from "react";
import ReactDOM from "react-dom";
import { BrowserRouter } from "react-router-dom";
import { EventSourceProvider } from "react-sse-hooks";
import "./index.css";
import Taker from "./Taker";
import theme from "./theme";
ReactDOM.render(
<React.StrictMode>
<ChakraProvider theme={theme}>
<EventSourceProvider>
<BrowserRouter>
<Taker />
</BrowserRouter>
</EventSourceProvider>
</ChakraProvider>
</React.StrictMode>,
document.getElementById("root"),
);

41
frontend/src/theme.tsx

@ -0,0 +1,41 @@
import { extendTheme } from "@chakra-ui/react";
const theme = extendTheme({
textStyles: {
smGray: {
fontSize: "sm",
color: "gray.500",
},
mdGray: {
fontSize: "md",
color: "gray.500",
},
lgGray: {
fontSize: "lg",
color: "gray.500",
},
},
components: {
Button: {
variants: {
"primary": {
color: "white",
bg: "blue.500",
_hover: {
bg: "blue.300",
_disabled: {
bg: "blue.300",
},
},
size: "md",
},
"secondary": {
bg: "gray.200",
size: "md",
},
},
},
},
});
export default theme;

1
frontend/src/vite-env.d.ts

@ -0,0 +1 @@
/// <reference types="vite/client" />

13
frontend/taker.html

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/src/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Hermes Taker</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="./src/main_taker.tsx"></script>
</body>
</html>

20
frontend/tsconfig.json

@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ESNext",
"useDefineForClassFields": true,
"lib": ["DOM", "DOM.Iterable", "ESNext"],
"allowJs": false,
"skipLibCheck": false,
"esModuleInterop": false,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react"
},
"include": ["./src"]
}

50
frontend/vite.config.ts

@ -0,0 +1,50 @@
import { resolve } from "path";
import reactRefresh from "@vitejs/plugin-react-refresh";
import { defineConfig } from "vite";
// https://vitejs.dev/config/
export default defineConfig({
plugins: [
(
process.env.NODE_ENV !== "test"
? [reactRefresh()]
: []
),
],
build: {
rollupOptions: {
input: {
maker: resolve(__dirname, "maker.html"),
taker: resolve(__dirname, "taker.html"),
},
},
},
server: {
open: "/maker",
},
// server: {
// proxy: {
// '/foo': 'http://localhost:4567',
// '/api': {
// target: 'http://jsonplaceholder.typicode.com',
// changeOrigin: true,
// rewrite: (path) => path.replace(/^\/api/, '')
// },
// // with RegEx
// '^/fallback/.*': {
// target: 'http://jsonplaceholder.typicode.com',
// changeOrigin: true,
// rewrite: (path) => path.replace(/^\/fallback/, '')
// },
// // Using the proxy instance
// '/api': {
// target: 'http://jsonplaceholder.typicode.com',
// changeOrigin: true,
// configure: (proxy, options) => {
// // proxy will be an instance of 'http-proxy'
// }
// }
// }
// }
});

13297
frontend/yarn.lock

File diff suppressed because it is too large
Loading…
Cancel
Save