Daniel Karzel
3 years ago
committed by
Mariusz Klochowicz
58 changed files with 18614 additions and 315 deletions
@ -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" |
File diff suppressed because it is too large
@ -1,3 +1,3 @@ |
|||
[workspace] |
|||
members = ["cfd_protocol"] |
|||
members = ["cfd_protocol", "daemon"] |
|||
resolver = "2" |
|||
|
@ -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" |
@ -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. |
@ -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) |
|||
); |
@ -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": [] |
|||
} |
|||
} |
|||
} |
@ -0,0 +1,9 @@ |
|||
use anyhow::Result; |
|||
use daemon::routes_maker; |
|||
|
|||
#[rocket::main] |
|||
async fn main() -> Result<()> { |
|||
routes_maker::start_http().await?; |
|||
|
|||
Ok(()) |
|||
} |
@ -0,0 +1,9 @@ |
|||
use anyhow::Result; |
|||
use daemon::routes_taker; |
|||
|
|||
#[rocket::main] |
|||
async fn main() -> Result<()> { |
|||
routes_taker::start_http().await?; |
|||
|
|||
Ok(()) |
|||
} |
@ -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 |
|||
} |
|||
} |
@ -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), |
|||
} |
|||
} |
@ -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), |
|||
} |
|||
} |
@ -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; |
@ -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, |
|||
} |
@ -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}}}}"# |
|||
); |
|||
} |
|||
} |
@ -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(()) |
|||
} |
@ -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: {:?}", ¤t_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(()) |
|||
} |
@ -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 |
|||
} |
@ -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(()) |
|||
} |
@ -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) |
|||
} |
@ -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) |
|||
} |
@ -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 |
@ -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 |
@ -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" |
|||
] |
|||
} |
|||
|
@ -0,0 +1,5 @@ |
|||
node_modules |
|||
.DS_Store |
|||
dist |
|||
dist-ssr |
|||
*.local |
@ -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> |
@ -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", |
|||
}; |
@ -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"); |
@ -0,0 +1,9 @@ |
|||
module.exports = { |
|||
process() { |
|||
return "module.exports = {};"; |
|||
}, |
|||
getCacheKey() { |
|||
// The output is always the same.
|
|||
return "cssTransform"; |
|||
}, |
|||
}; |
@ -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> |
@ -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" |
|||
] |
|||
} |
|||
} |
@ -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); |
|||
} |
@ -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, |
|||
); |
|||
}); |
@ -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> |
|||
); |
|||
} |
@ -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> |
|||
); |
|||
} |
@ -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; |
@ -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> |
|||
); |
|||
} |
@ -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> |
|||
); |
|||
} |
@ -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; |
|||
} |
@ -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> |
|||
); |
|||
} |
@ -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; |
|||
} |
After Width: | Height: | Size: 1.5 KiB |
@ -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; |
|||
} |
@ -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"), |
|||
); |
After Width: | Height: | Size: 2.6 KiB |
@ -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"), |
|||
); |
@ -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"), |
|||
); |
@ -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"), |
|||
); |
@ -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; |
@ -0,0 +1 @@ |
|||
/// <reference types="vite/client" />
|
@ -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> |
@ -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"] |
|||
} |
@ -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'
|
|||
// }
|
|||
// }
|
|||
// }
|
|||
// }
|
|||
}); |
File diff suppressed because it is too large
Loading…
Reference in new issue