Initial commit
2
.gitattributes
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
# Auto detect text files and perform LF normalization
|
||||
* text=auto
|
24
.gitignore
vendored
Normal file
@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
3
.vscode/extensions.json
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["tauri-apps.tauri-vscode", "rust-lang.rust-analyzer"]
|
||||
}
|
7
README.md
Normal file
@ -0,0 +1,7 @@
|
||||
# Tauri + React + Typescript
|
||||
|
||||
This template should help get you started developing with Tauri, React and Typescript in Vite.
|
||||
|
||||
## Recommended IDE Setup
|
||||
|
||||
- [VS Code](https://code.visualstudio.com/) + [Tauri](https://marketplace.visualstudio.com/items?itemName=tauri-apps.tauri-vscode) + [rust-analyzer](https://marketplace.visualstudio.com/items?itemName=rust-lang.rust-analyzer)
|
14
index.html
Normal file
@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Tauri + React + Typescript</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/renderer/index.tsx"></script>
|
||||
</body>
|
||||
</html>
|
47
package.json
Normal file
@ -0,0 +1,47 @@
|
||||
{
|
||||
"name": "unstoppableswap-gui-rs",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"tauri": "tauri"
|
||||
},
|
||||
"dependencies": {
|
||||
"@material-ui/core": "^4.12.4",
|
||||
"@material-ui/icons": "^4.11.3",
|
||||
"@material-ui/lab": "^4.0.0-alpha.61",
|
||||
"@reduxjs/toolkit": "^2.2.6",
|
||||
"@tauri-apps/api": ">=2.0.0-beta.0",
|
||||
"@tauri-apps/plugin-shell": ">=2.0.0-beta.0",
|
||||
"humanize-duration": "^3.32.1",
|
||||
"lodash": "^4.17.21",
|
||||
"multiaddr": "^10.0.1",
|
||||
"notistack": "^3.0.1",
|
||||
"pino": "^9.2.0",
|
||||
"pino-pretty": "^11.2.1",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-qr-code": "^2.0.15",
|
||||
"react-redux": "^9.1.2",
|
||||
"react-router-dom": "^6.24.1",
|
||||
"semver": "^7.6.2",
|
||||
"virtua": "^0.33.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": ">=2.0.0-beta.0",
|
||||
"@types/humanize-duration": "^3.27.4",
|
||||
"@types/lodash": "^4.17.6",
|
||||
"@types/node": "^20.14.10",
|
||||
"@types/react": "^18.2.15",
|
||||
"@types/react-dom": "^18.2.7",
|
||||
"@types/semver": "^7.5.8",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"internal-ip": "^7.0.0",
|
||||
"typescript": "^5.2.2",
|
||||
"vite": "^5.3.1",
|
||||
"vite-tsconfig-paths": "^4.3.2"
|
||||
}
|
||||
}
|
6
public/tauri.svg
Normal file
@ -0,0 +1,6 @@
|
||||
<svg width="206" height="231" viewBox="0 0 206 231" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M143.143 84C143.143 96.1503 133.293 106 121.143 106C108.992 106 99.1426 96.1503 99.1426 84C99.1426 71.8497 108.992 62 121.143 62C133.293 62 143.143 71.8497 143.143 84Z" fill="#FFC131"/>
|
||||
<ellipse cx="84.1426" cy="147" rx="22" ry="22" transform="rotate(180 84.1426 147)" fill="#24C8DB"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M166.738 154.548C157.86 160.286 148.023 164.269 137.757 166.341C139.858 160.282 141 153.774 141 147C141 144.543 140.85 142.121 140.558 139.743C144.975 138.204 149.215 136.139 153.183 133.575C162.73 127.404 170.292 118.608 174.961 108.244C179.63 97.8797 181.207 86.3876 179.502 75.1487C177.798 63.9098 172.884 53.4021 165.352 44.8883C157.82 36.3744 147.99 30.2165 137.042 27.1546C126.095 24.0926 114.496 24.2568 103.64 27.6274C92.7839 30.998 83.1319 37.4317 75.8437 46.1553C74.9102 47.2727 74.0206 48.4216 73.176 49.5993C61.9292 50.8488 51.0363 54.0318 40.9629 58.9556C44.2417 48.4586 49.5653 38.6591 56.679 30.1442C67.0505 17.7298 80.7861 8.57426 96.2354 3.77762C111.685 -1.01901 128.19 -1.25267 143.769 3.10474C159.348 7.46215 173.337 16.2252 184.056 28.3411C194.775 40.457 201.767 55.4101 204.193 71.404C206.619 87.3978 204.374 103.752 197.73 118.501C191.086 133.25 180.324 145.767 166.738 154.548ZM41.9631 74.275L62.5557 76.8042C63.0459 72.813 63.9401 68.9018 65.2138 65.1274C57.0465 67.0016 49.2088 70.087 41.9631 74.275Z" fill="#FFC131"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M38.4045 76.4519C47.3493 70.6709 57.2677 66.6712 67.6171 64.6132C65.2774 70.9669 64 77.8343 64 85.0001C64 87.1434 64.1143 89.26 64.3371 91.3442C60.0093 92.8732 55.8533 94.9092 51.9599 97.4256C42.4128 103.596 34.8505 112.392 30.1816 122.756C25.5126 133.12 23.9357 144.612 25.6403 155.851C27.3449 167.09 32.2584 177.598 39.7906 186.112C47.3227 194.626 57.153 200.784 68.1003 203.846C79.0476 206.907 90.6462 206.743 101.502 203.373C112.359 200.002 122.011 193.568 129.299 184.845C130.237 183.722 131.131 182.567 131.979 181.383C143.235 180.114 154.132 176.91 164.205 171.962C160.929 182.49 155.596 192.319 148.464 200.856C138.092 213.27 124.357 222.426 108.907 227.222C93.458 232.019 76.9524 232.253 61.3736 227.895C45.7948 223.538 31.8055 214.775 21.0867 202.659C10.3679 190.543 3.37557 175.59 0.949823 159.596C-1.47592 143.602 0.768139 127.248 7.41237 112.499C14.0566 97.7497 24.8183 85.2327 38.4045 76.4519ZM163.062 156.711L163.062 156.711C162.954 156.773 162.846 156.835 162.738 156.897C162.846 156.835 162.954 156.773 163.062 156.711Z" fill="#24C8DB"/>
|
||||
</svg>
|
After Width: | Height: | Size: 2.5 KiB |
1
public/vite.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
After Width: | Height: | Size: 1.5 KiB |
7
src-tauri/.gitignore
vendored
Normal file
@ -0,0 +1,7 @@
|
||||
# Generated by Cargo
|
||||
# will have compiled files and executables
|
||||
/target/
|
||||
|
||||
# Generated by Tauri
|
||||
# will have schema files for capabilities auto-completion
|
||||
/gen/schemas
|
4346
src-tauri/Cargo.lock
generated
Normal file
22
src-tauri/Cargo.toml
Normal file
@ -0,0 +1,22 @@
|
||||
[package]
|
||||
name = "unstoppableswap-gui-rs"
|
||||
version = "0.0.0"
|
||||
description = "A Tauri App"
|
||||
authors = ["you"]
|
||||
edition = "2021"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[lib]
|
||||
name = "unstoppableswap_gui_rs_lib"
|
||||
crate-type = ["lib", "cdylib", "staticlib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2.0.0-beta", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2.0.0-beta", features = [] }
|
||||
tauri-plugin-shell = "2.0.0-beta"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
|
3
src-tauri/build.rs
Normal file
@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
17
src-tauri/capabilities/default.json
Normal file
@ -0,0 +1,17 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Capability for the main window",
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"path:default",
|
||||
"event:default",
|
||||
"window:default",
|
||||
"app:default",
|
||||
"image:default",
|
||||
"resources:default",
|
||||
"menu:default",
|
||||
"tray:default",
|
||||
"shell:allow-open"
|
||||
]
|
||||
}
|
BIN
src-tauri/icons/128x128.png
Normal file
After Width: | Height: | Size: 3.4 KiB |
BIN
src-tauri/icons/128x128@2x.png
Normal file
After Width: | Height: | Size: 6.8 KiB |
BIN
src-tauri/icons/32x32.png
Normal file
After Width: | Height: | Size: 974 B |
BIN
src-tauri/icons/Square107x107Logo.png
Normal file
After Width: | Height: | Size: 2.8 KiB |
BIN
src-tauri/icons/Square142x142Logo.png
Normal file
After Width: | Height: | Size: 3.8 KiB |
BIN
src-tauri/icons/Square150x150Logo.png
Normal file
After Width: | Height: | Size: 3.9 KiB |
BIN
src-tauri/icons/Square284x284Logo.png
Normal file
After Width: | Height: | Size: 7.6 KiB |
BIN
src-tauri/icons/Square30x30Logo.png
Normal file
After Width: | Height: | Size: 903 B |
BIN
src-tauri/icons/Square310x310Logo.png
Normal file
After Width: | Height: | Size: 8.4 KiB |
BIN
src-tauri/icons/Square44x44Logo.png
Normal file
After Width: | Height: | Size: 1.3 KiB |
BIN
src-tauri/icons/Square71x71Logo.png
Normal file
After Width: | Height: | Size: 2.0 KiB |
BIN
src-tauri/icons/Square89x89Logo.png
Normal file
After Width: | Height: | Size: 2.4 KiB |
BIN
src-tauri/icons/StoreLogo.png
Normal file
After Width: | Height: | Size: 1.5 KiB |
BIN
src-tauri/icons/icon.icns
Normal file
BIN
src-tauri/icons/icon.ico
Normal file
After Width: | Height: | Size: 85 KiB |
BIN
src-tauri/icons/icon.png
Normal file
After Width: | Height: | Size: 14 KiB |
14
src-tauri/src/lib.rs
Normal file
@ -0,0 +1,14 @@
|
||||
// Learn more about Tauri commands at https://tauri.app/v1/guides/features/command
|
||||
#[tauri::command]
|
||||
fn greet(name: &str) -> String {
|
||||
format!("Hello, {}! You've been greeted from Rust!", name)
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_shell::init())
|
||||
.invoke_handler(tauri::generate_handler![greet])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
6
src-tauri/src/main.rs
Normal file
@ -0,0 +1,6 @@
|
||||
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
unstoppableswap_gui_rs_lib::run()
|
||||
}
|
34
src-tauri/tauri.conf.json
Normal file
@ -0,0 +1,34 @@
|
||||
{
|
||||
"productName": "unstoppableswap-gui-rs",
|
||||
"version": "0.0.0",
|
||||
"identifier": "com.tauri.dev",
|
||||
"build": {
|
||||
"beforeDevCommand": "yarn dev",
|
||||
"devUrl": "http://localhost:1420",
|
||||
"beforeBuildCommand": "yarn build",
|
||||
"frontendDist": "../dist"
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"title": "unstoppableswap-gui-rs",
|
||||
"width": 800,
|
||||
"height": 600
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": null
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
]
|
||||
}
|
||||
}
|
28
src/models/apiModel.ts
Normal file
@ -0,0 +1,28 @@
|
||||
export interface ExtendedProviderStatus extends ProviderStatus {
|
||||
uptime?: number;
|
||||
age?: number;
|
||||
relevancy?: number;
|
||||
version?: string;
|
||||
recommended?: boolean;
|
||||
}
|
||||
|
||||
export interface ProviderStatus extends ProviderQuote, Provider {}
|
||||
|
||||
export interface ProviderQuote {
|
||||
price: number;
|
||||
minSwapAmount: number;
|
||||
maxSwapAmount: number;
|
||||
}
|
||||
|
||||
export interface Provider {
|
||||
multiAddr: string;
|
||||
testnet: boolean;
|
||||
peerId: string;
|
||||
}
|
||||
|
||||
export interface Alert {
|
||||
id: number;
|
||||
title: string;
|
||||
body: string;
|
||||
severity: 'info' | 'warning' | 'error';
|
||||
}
|
406
src/models/cliModel.ts
Normal file
@ -0,0 +1,406 @@
|
||||
export enum SwapSpawnType {
|
||||
INIT = 'init',
|
||||
RESUME = 'resume',
|
||||
CANCEL_REFUND = 'cancel-refund',
|
||||
}
|
||||
|
||||
export type CliLogSpanType = string | 'BitcoinWalletSubscription';
|
||||
|
||||
export interface CliLog {
|
||||
timestamp: string;
|
||||
level: 'DEBUG' | 'INFO' | 'WARN' | 'ERROR' | 'TRACE';
|
||||
fields: {
|
||||
message: string;
|
||||
[index: string]: unknown;
|
||||
};
|
||||
spans?: {
|
||||
name: CliLogSpanType;
|
||||
[index: string]: unknown;
|
||||
}[];
|
||||
}
|
||||
|
||||
export function isCliLog(log: unknown): log is CliLog {
|
||||
if (log && typeof log === 'object') {
|
||||
return (
|
||||
'timestamp' in (log as CliLog) &&
|
||||
'level' in (log as CliLog) &&
|
||||
'fields' in (log as CliLog) &&
|
||||
typeof (log as CliLog).fields?.message === 'string'
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export interface CliLogStartedRpcServer extends CliLog {
|
||||
fields: {
|
||||
message: 'Started RPC server';
|
||||
addr: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function isCliLogStartedRpcServer(
|
||||
log: CliLog,
|
||||
): log is CliLogStartedRpcServer {
|
||||
return log.fields.message === 'Started RPC server';
|
||||
}
|
||||
|
||||
export interface CliLogReleasingSwapLockLog extends CliLog {
|
||||
fields: {
|
||||
message: 'Releasing swap lock';
|
||||
swap_id: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function isCliLogReleasingSwapLockLog(
|
||||
log: CliLog,
|
||||
): log is CliLogReleasingSwapLockLog {
|
||||
return log.fields.message === 'Releasing swap lock';
|
||||
}
|
||||
|
||||
export interface CliLogApiCallError extends CliLog {
|
||||
fields: {
|
||||
message: 'API call resulted in an error';
|
||||
err: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function isCliLogApiCallError(log: CliLog): log is CliLogApiCallError {
|
||||
return log.fields.message === 'API call resulted in an error';
|
||||
}
|
||||
|
||||
export interface CliLogAcquiringSwapLockLog extends CliLog {
|
||||
fields: {
|
||||
message: 'Acquiring swap lock';
|
||||
swap_id: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function isCliLogAcquiringSwapLockLog(
|
||||
log: CliLog,
|
||||
): log is CliLogAcquiringSwapLockLog {
|
||||
return log.fields.message === 'Acquiring swap lock';
|
||||
}
|
||||
|
||||
export interface CliLogReceivedQuote extends CliLog {
|
||||
fields: {
|
||||
message: 'Received quote';
|
||||
price: string;
|
||||
minimum_amount: string;
|
||||
maximum_amount: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function isCliLogReceivedQuote(log: CliLog): log is CliLogReceivedQuote {
|
||||
return log.fields.message === 'Received quote';
|
||||
}
|
||||
|
||||
export interface CliLogWaitingForBtcDeposit extends CliLog {
|
||||
fields: {
|
||||
message: 'Waiting for Bitcoin deposit';
|
||||
deposit_address: string;
|
||||
min_deposit_until_swap_will_start: string;
|
||||
max_deposit_until_maximum_amount_is_reached: string;
|
||||
max_giveable: string;
|
||||
minimum_amount: string;
|
||||
maximum_amount: string;
|
||||
min_bitcoin_lock_tx_fee: string;
|
||||
price: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function isCliLogWaitingForBtcDeposit(
|
||||
log: CliLog,
|
||||
): log is CliLogWaitingForBtcDeposit {
|
||||
return log.fields.message === 'Waiting for Bitcoin deposit';
|
||||
}
|
||||
|
||||
export interface CliLogReceivedBtc extends CliLog {
|
||||
fields: {
|
||||
message: 'Received Bitcoin';
|
||||
max_giveable: string;
|
||||
new_balance: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function isCliLogReceivedBtc(log: CliLog): log is CliLogReceivedBtc {
|
||||
return log.fields.message === 'Received Bitcoin';
|
||||
}
|
||||
|
||||
export interface CliLogDeterminedSwapAmount extends CliLog {
|
||||
fields: {
|
||||
message: 'Determined swap amount';
|
||||
amount: string;
|
||||
fees: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function isCliLogDeterminedSwapAmount(
|
||||
log: CliLog,
|
||||
): log is CliLogDeterminedSwapAmount {
|
||||
return log.fields.message === 'Determined swap amount';
|
||||
}
|
||||
|
||||
export interface CliLogStartedSwap extends CliLog {
|
||||
fields: {
|
||||
message: 'Starting new swap';
|
||||
swap_id: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function isCliLogStartedSwap(log: CliLog): log is CliLogStartedSwap {
|
||||
return log.fields.message === 'Starting new swap';
|
||||
}
|
||||
|
||||
export interface CliLogPublishedBtcTx extends CliLog {
|
||||
fields: {
|
||||
message: 'Published Bitcoin transaction';
|
||||
txid: string;
|
||||
kind: 'lock' | 'cancel' | 'withdraw' | 'refund';
|
||||
};
|
||||
}
|
||||
|
||||
export function isCliLogPublishedBtcTx(
|
||||
log: CliLog,
|
||||
): log is CliLogPublishedBtcTx {
|
||||
return log.fields.message === 'Published Bitcoin transaction';
|
||||
}
|
||||
|
||||
export interface CliLogBtcTxFound extends CliLog {
|
||||
fields: {
|
||||
message: 'Found relevant Bitcoin transaction';
|
||||
txid: string;
|
||||
status: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function isCliLogBtcTxFound(log: CliLog): log is CliLogBtcTxFound {
|
||||
return log.fields.message === 'Found relevant Bitcoin transaction';
|
||||
}
|
||||
|
||||
export interface CliLogBtcTxStatusChanged extends CliLog {
|
||||
fields: {
|
||||
message: 'Bitcoin transaction status changed';
|
||||
txid: string;
|
||||
new_status: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function isCliLogBtcTxStatusChanged(
|
||||
log: CliLog,
|
||||
): log is CliLogBtcTxStatusChanged {
|
||||
return log.fields.message === 'Bitcoin transaction status changed';
|
||||
}
|
||||
|
||||
export interface CliLogAliceLockedXmr extends CliLog {
|
||||
fields: {
|
||||
message: 'Alice locked Monero';
|
||||
txid: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function isCliLogAliceLockedXmr(
|
||||
log: CliLog,
|
||||
): log is CliLogAliceLockedXmr {
|
||||
return log.fields.message === 'Alice locked Monero';
|
||||
}
|
||||
|
||||
export interface CliLogReceivedXmrLockTxConfirmation extends CliLog {
|
||||
fields: {
|
||||
message: 'Received new confirmation for Monero lock tx';
|
||||
txid: string;
|
||||
seen_confirmations: string;
|
||||
needed_confirmations: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function isCliLogReceivedXmrLockTxConfirmation(
|
||||
log: CliLog,
|
||||
): log is CliLogReceivedXmrLockTxConfirmation {
|
||||
return log.fields.message === 'Received new confirmation for Monero lock tx';
|
||||
}
|
||||
|
||||
export interface CliLogAdvancingState extends CliLog {
|
||||
fields: {
|
||||
message: 'Advancing state';
|
||||
state:
|
||||
| 'quote has been requested'
|
||||
| 'execution setup done'
|
||||
| 'btc is locked'
|
||||
| 'XMR lock transaction transfer proof received'
|
||||
| 'xmr is locked'
|
||||
| 'encrypted signature is sent'
|
||||
| 'btc is redeemed'
|
||||
| 'cancel timelock is expired'
|
||||
| 'btc is cancelled'
|
||||
| 'btc is refunded'
|
||||
| 'xmr is redeemed'
|
||||
| 'btc is punished'
|
||||
| 'safely aborted';
|
||||
};
|
||||
}
|
||||
|
||||
export function isCliLogAdvancingState(
|
||||
log: CliLog,
|
||||
): log is CliLogAdvancingState {
|
||||
return log.fields.message === 'Advancing state';
|
||||
}
|
||||
|
||||
export interface CliLogRedeemedXmr extends CliLog {
|
||||
fields: {
|
||||
message: 'Successfully transferred XMR to wallet';
|
||||
monero_receive_address: string;
|
||||
txid: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function isCliLogRedeemedXmr(log: CliLog): log is CliLogRedeemedXmr {
|
||||
return log.fields.message === 'Successfully transferred XMR to wallet';
|
||||
}
|
||||
|
||||
export interface YouHaveBeenPunishedCliLog extends CliLog {
|
||||
fields: {
|
||||
message: 'You have been punished for not refunding in time';
|
||||
};
|
||||
}
|
||||
|
||||
export function isYouHaveBeenPunishedCliLog(
|
||||
log: CliLog,
|
||||
): log is YouHaveBeenPunishedCliLog {
|
||||
return (
|
||||
log.fields.message === 'You have been punished for not refunding in time'
|
||||
);
|
||||
}
|
||||
|
||||
function getCliLogSpanAttribute<T>(log: CliLog, key: string): T | null {
|
||||
const span = log.spans?.find((s) => s[key]);
|
||||
if (!span) {
|
||||
return null;
|
||||
}
|
||||
return span[key] as T;
|
||||
}
|
||||
|
||||
export function getCliLogSpanSwapId(log: CliLog): string | null {
|
||||
return getCliLogSpanAttribute<string>(log, 'swap_id');
|
||||
}
|
||||
|
||||
export function getCliLogSpanLogReferenceId(log: CliLog): string | null {
|
||||
return (
|
||||
getCliLogSpanAttribute<string>(log, 'log_reference_id')?.replace(
|
||||
/"/g,
|
||||
'',
|
||||
) || null
|
||||
);
|
||||
}
|
||||
|
||||
export function hasCliLogOneOfMultipleSpans(
|
||||
log: CliLog,
|
||||
spanNames: string[],
|
||||
): boolean {
|
||||
return log.spans?.some((s) => spanNames.includes(s.name)) ?? false;
|
||||
}
|
||||
|
||||
export interface CliLogStartedSyncingMoneroWallet extends CliLog {
|
||||
fields: {
|
||||
message: 'Syncing Monero wallet';
|
||||
current_sync_height?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export function isCliLogStartedSyncingMoneroWallet(
|
||||
log: CliLog,
|
||||
): log is CliLogStartedSyncingMoneroWallet {
|
||||
return log.fields.message === 'Syncing Monero wallet';
|
||||
}
|
||||
|
||||
export interface CliLogFinishedSyncingMoneroWallet extends CliLog {
|
||||
fields: {
|
||||
message: 'Synced Monero wallet';
|
||||
};
|
||||
}
|
||||
|
||||
export interface CliLogFailedToSyncMoneroWallet extends CliLog {
|
||||
fields: {
|
||||
message: 'Failed to sync Monero wallet';
|
||||
error: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function isCliLogFailedToSyncMoneroWallet(
|
||||
log: CliLog,
|
||||
): log is CliLogFailedToSyncMoneroWallet {
|
||||
return log.fields.message === 'Failed to sync Monero wallet';
|
||||
}
|
||||
|
||||
export function isCliLogFinishedSyncingMoneroWallet(
|
||||
log: CliLog,
|
||||
): log is CliLogFinishedSyncingMoneroWallet {
|
||||
return log.fields.message === 'Monero wallet synced';
|
||||
}
|
||||
|
||||
export interface CliLogDownloadingMoneroWalletRpc extends CliLog {
|
||||
fields: {
|
||||
message: 'Downloading monero-wallet-rpc';
|
||||
progress: string;
|
||||
size: string;
|
||||
download_url: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function isCliLogDownloadingMoneroWalletRpc(
|
||||
log: CliLog,
|
||||
): log is CliLogDownloadingMoneroWalletRpc {
|
||||
return log.fields.message === 'Downloading monero-wallet-rpc';
|
||||
}
|
||||
|
||||
export interface CliLogStartedSyncingMoneroWallet extends CliLog {
|
||||
fields: {
|
||||
message: 'Syncing Monero wallet';
|
||||
current_sync_height?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface CliLogDownloadingMoneroWalletRpc extends CliLog {
|
||||
fields: {
|
||||
message: 'Downloading monero-wallet-rpc';
|
||||
progress: string;
|
||||
size: string;
|
||||
download_url: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface CliLogGotNotificationForNewBlock extends CliLog {
|
||||
fields: {
|
||||
message: 'Got notification for new block';
|
||||
block_height: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function isCliLogGotNotificationForNewBlock(
|
||||
log: CliLog,
|
||||
): log is CliLogGotNotificationForNewBlock {
|
||||
return log.fields.message === 'Got notification for new block';
|
||||
}
|
||||
|
||||
export interface CliLogAttemptingToCooperativelyRedeemXmr extends CliLog {
|
||||
fields: {
|
||||
message: 'Attempting to cooperatively redeem XMR after being punished';
|
||||
};
|
||||
}
|
||||
|
||||
export function isCliLogAttemptingToCooperativelyRedeemXmr(
|
||||
log: CliLog,
|
||||
): log is CliLogAttemptingToCooperativelyRedeemXmr {
|
||||
return log.fields.message === 'Attempting to cooperatively redeem XMR after being punished';
|
||||
}
|
||||
|
||||
export interface CliLogAliceHasAcceptedOurRequestToCooperativelyRedeemTheXmr extends CliLog {
|
||||
fields: {
|
||||
message: 'Alice has accepted our request to cooperatively redeem the XMR';
|
||||
};
|
||||
}
|
||||
|
||||
export function isCliLogAliceHasAcceptedOurRequestToCooperativelyRedeemTheXmr(
|
||||
log: CliLog,
|
||||
): log is CliLogAliceHasAcceptedOurRequestToCooperativelyRedeemTheXmr {
|
||||
return log.fields.message === 'Alice has accepted our request to cooperatively redeem the XMR';
|
||||
}
|
4
src/models/downloaderModel.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export interface Binary {
|
||||
dirPath: string; // Path without filename appended
|
||||
fileName: string;
|
||||
}
|
336
src/models/rpcModel.ts
Normal file
@ -0,0 +1,336 @@
|
||||
import { piconerosToXmr, satsToBtc } from 'utils/conversionUtils';
|
||||
import { exhaustiveGuard } from 'utils/typescriptUtils';
|
||||
|
||||
export enum RpcMethod {
|
||||
GET_BTC_BALANCE = 'get_bitcoin_balance',
|
||||
WITHDRAW_BTC = 'withdraw_btc',
|
||||
BUY_XMR = 'buy_xmr',
|
||||
RESUME_SWAP = 'resume_swap',
|
||||
LIST_SELLERS = 'list_sellers',
|
||||
CANCEL_REFUND_SWAP = 'cancel_refund_swap',
|
||||
GET_SWAP_INFO = 'get_swap_info',
|
||||
SUSPEND_CURRENT_SWAP = 'suspend_current_swap',
|
||||
GET_HISTORY = 'get_history',
|
||||
GET_MONERO_RECOVERY_KEYS = 'get_monero_recovery_info',
|
||||
}
|
||||
|
||||
export enum RpcProcessStateType {
|
||||
STARTED = 'starting...',
|
||||
LISTENING_FOR_CONNECTIONS = 'running',
|
||||
EXITED = 'exited',
|
||||
NOT_STARTED = 'not started',
|
||||
}
|
||||
|
||||
export type RawRpcResponseSuccess<T> = {
|
||||
jsonrpc: string;
|
||||
id: string;
|
||||
result: T;
|
||||
};
|
||||
|
||||
export type RawRpcResponseError = {
|
||||
jsonrpc: string;
|
||||
id: string;
|
||||
error: { code: number; message: string };
|
||||
};
|
||||
|
||||
export type RawRpcResponse<T> = RawRpcResponseSuccess<T> | RawRpcResponseError;
|
||||
|
||||
export function isSuccessResponse<T>(
|
||||
response: RawRpcResponse<T>,
|
||||
): response is RawRpcResponseSuccess<T> {
|
||||
return 'result' in response;
|
||||
}
|
||||
|
||||
export function isErrorResponse<T>(
|
||||
response: RawRpcResponse<T>,
|
||||
): response is RawRpcResponseError {
|
||||
return 'error' in response;
|
||||
}
|
||||
|
||||
export interface RpcSellerStatus {
|
||||
status:
|
||||
| {
|
||||
Online: {
|
||||
price: number;
|
||||
min_quantity: number;
|
||||
max_quantity: number;
|
||||
};
|
||||
}
|
||||
| 'Unreachable';
|
||||
multiaddr: string;
|
||||
}
|
||||
|
||||
export interface WithdrawBitcoinResponse {
|
||||
txid: string;
|
||||
}
|
||||
|
||||
export interface BuyXmrResponse {
|
||||
swapId: string;
|
||||
}
|
||||
|
||||
export type SwapTimelockInfoNone = {
|
||||
None: {
|
||||
blocks_left: number;
|
||||
};
|
||||
};
|
||||
|
||||
export type SwapTimelockInfoCancelled = {
|
||||
Cancel: {
|
||||
blocks_left: number;
|
||||
};
|
||||
};
|
||||
|
||||
export type SwapTimelockInfoPunished = 'Punish';
|
||||
|
||||
export type SwapTimelockInfo =
|
||||
| SwapTimelockInfoNone
|
||||
| SwapTimelockInfoCancelled
|
||||
| SwapTimelockInfoPunished;
|
||||
|
||||
export function isSwapTimelockInfoNone(
|
||||
info: SwapTimelockInfo,
|
||||
): info is SwapTimelockInfoNone {
|
||||
return typeof info === 'object' && 'None' in info;
|
||||
}
|
||||
|
||||
export function isSwapTimelockInfoCancelled(
|
||||
info: SwapTimelockInfo,
|
||||
): info is SwapTimelockInfoCancelled {
|
||||
return typeof info === 'object' && 'Cancel' in info;
|
||||
}
|
||||
|
||||
export function isSwapTimelockInfoPunished(
|
||||
info: SwapTimelockInfo,
|
||||
): info is SwapTimelockInfoPunished {
|
||||
return info === 'Punish';
|
||||
}
|
||||
|
||||
export type SwapSellerInfo = {
|
||||
peerId: string;
|
||||
addresses: string[];
|
||||
};
|
||||
|
||||
export interface GetSwapInfoResponse {
|
||||
swapId: string;
|
||||
completed: boolean;
|
||||
seller: SwapSellerInfo;
|
||||
startDate: string;
|
||||
stateName: SwapStateName;
|
||||
timelock: null | SwapTimelockInfo;
|
||||
txLockId: string;
|
||||
txCancelFee: number;
|
||||
txRefundFee: number;
|
||||
txLockFee: number;
|
||||
btcAmount: number;
|
||||
xmrAmount: number;
|
||||
btcRefundAddress: string;
|
||||
cancelTimelock: number;
|
||||
punishTimelock: number;
|
||||
}
|
||||
|
||||
export type MoneroRecoveryResponse = {
|
||||
address: string;
|
||||
spend_key: string;
|
||||
view_key: string;
|
||||
restore_height: number;
|
||||
};
|
||||
|
||||
export interface BalanceBitcoinResponse {
|
||||
balance: number;
|
||||
}
|
||||
|
||||
export interface GetHistoryResponse {
|
||||
swaps: [swapId: string, stateName: SwapStateName][];
|
||||
}
|
||||
|
||||
export enum SwapStateName {
|
||||
Started = 'quote has been requested',
|
||||
SwapSetupCompleted = 'execution setup done',
|
||||
BtcLocked = 'btc is locked',
|
||||
XmrLockProofReceived = 'XMR lock transaction transfer proof received',
|
||||
XmrLocked = 'xmr is locked',
|
||||
EncSigSent = 'encrypted signature is sent',
|
||||
BtcRedeemed = 'btc is redeemed',
|
||||
CancelTimelockExpired = 'cancel timelock is expired',
|
||||
BtcCancelled = 'btc is cancelled',
|
||||
BtcRefunded = 'btc is refunded',
|
||||
XmrRedeemed = 'xmr is redeemed',
|
||||
BtcPunished = 'btc is punished',
|
||||
SafelyAborted = 'safely aborted',
|
||||
}
|
||||
|
||||
export type SwapStateNameRunningSwap = Exclude<
|
||||
SwapStateName,
|
||||
| SwapStateName.Started
|
||||
| SwapStateName.SwapSetupCompleted
|
||||
| SwapStateName.BtcRefunded
|
||||
| SwapStateName.BtcPunished
|
||||
| SwapStateName.SafelyAborted
|
||||
| SwapStateName.XmrRedeemed
|
||||
>;
|
||||
|
||||
export type GetSwapInfoResponseRunningSwap = GetSwapInfoResponse & {
|
||||
stateName: SwapStateNameRunningSwap;
|
||||
};
|
||||
|
||||
export function isSwapStateNameRunningSwap(
|
||||
state: SwapStateName,
|
||||
): state is SwapStateNameRunningSwap {
|
||||
return ![
|
||||
SwapStateName.Started,
|
||||
SwapStateName.SwapSetupCompleted,
|
||||
SwapStateName.BtcRefunded,
|
||||
SwapStateName.BtcPunished,
|
||||
SwapStateName.SafelyAborted,
|
||||
SwapStateName.XmrRedeemed,
|
||||
].includes(state);
|
||||
}
|
||||
|
||||
export type SwapStateNameCompletedSwap =
|
||||
| SwapStateName.XmrRedeemed
|
||||
| SwapStateName.BtcRefunded
|
||||
| SwapStateName.BtcPunished
|
||||
| SwapStateName.SafelyAborted;
|
||||
|
||||
export function isSwapStateNameCompletedSwap(
|
||||
state: SwapStateName,
|
||||
): state is SwapStateNameCompletedSwap {
|
||||
return [
|
||||
SwapStateName.XmrRedeemed,
|
||||
SwapStateName.BtcRefunded,
|
||||
SwapStateName.BtcPunished,
|
||||
SwapStateName.SafelyAborted,
|
||||
].includes(state);
|
||||
}
|
||||
|
||||
export type SwapStateNamePossiblyCancellableSwap =
|
||||
| SwapStateName.BtcLocked
|
||||
| SwapStateName.XmrLockProofReceived
|
||||
| SwapStateName.XmrLocked
|
||||
| SwapStateName.EncSigSent
|
||||
| SwapStateName.CancelTimelockExpired;
|
||||
|
||||
/**
|
||||
Checks if a swap is in a state where it can possibly be cancelled
|
||||
|
||||
The following conditions must be met:
|
||||
- The bitcoin must be locked
|
||||
- The bitcoin must not be redeemed
|
||||
- The bitcoin must not be cancelled
|
||||
- The bitcoin must not be refunded
|
||||
- The bitcoin must not be punished
|
||||
|
||||
See: https://github.com/comit-network/xmr-btc-swap/blob/7023e75bb51ab26dff4c8fcccdc855d781ca4b15/swap/src/cli/cancel.rs#L16-L35
|
||||
*/
|
||||
export function isSwapStateNamePossiblyCancellableSwap(
|
||||
state: SwapStateName,
|
||||
): state is SwapStateNamePossiblyCancellableSwap {
|
||||
return [
|
||||
SwapStateName.BtcLocked,
|
||||
SwapStateName.XmrLockProofReceived,
|
||||
SwapStateName.XmrLocked,
|
||||
SwapStateName.EncSigSent,
|
||||
SwapStateName.CancelTimelockExpired,
|
||||
].includes(state);
|
||||
}
|
||||
|
||||
export type SwapStateNamePossiblyRefundableSwap =
|
||||
| SwapStateName.BtcLocked
|
||||
| SwapStateName.XmrLockProofReceived
|
||||
| SwapStateName.XmrLocked
|
||||
| SwapStateName.EncSigSent
|
||||
| SwapStateName.CancelTimelockExpired
|
||||
| SwapStateName.BtcCancelled;
|
||||
|
||||
/**
|
||||
Checks if a swap is in a state where it can possibly be refunded (meaning it's not impossible)
|
||||
|
||||
The following conditions must be met:
|
||||
- The bitcoin must be locked
|
||||
- The bitcoin must not be redeemed
|
||||
- The bitcoin must not be refunded
|
||||
- The bitcoin must not be punished
|
||||
|
||||
See: https://github.com/comit-network/xmr-btc-swap/blob/7023e75bb51ab26dff4c8fcccdc855d781ca4b15/swap/src/cli/refund.rs#L16-L34
|
||||
*/
|
||||
export function isSwapStateNamePossiblyRefundableSwap(
|
||||
state: SwapStateName,
|
||||
): state is SwapStateNamePossiblyRefundableSwap {
|
||||
return [
|
||||
SwapStateName.BtcLocked,
|
||||
SwapStateName.XmrLockProofReceived,
|
||||
SwapStateName.XmrLocked,
|
||||
SwapStateName.EncSigSent,
|
||||
SwapStateName.CancelTimelockExpired,
|
||||
SwapStateName.BtcCancelled,
|
||||
].includes(state);
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard for GetSwapInfoResponseRunningSwap
|
||||
* "running" means the swap is in progress and not yet completed
|
||||
* If a swap is not "running" it means it is either completed or no Bitcoin have been locked yet
|
||||
* @param response
|
||||
*/
|
||||
export function isGetSwapInfoResponseRunningSwap(
|
||||
response: GetSwapInfoResponse,
|
||||
): response is GetSwapInfoResponseRunningSwap {
|
||||
return isSwapStateNameRunningSwap(response.stateName);
|
||||
}
|
||||
|
||||
export function isSwapMoneroRecoverable(swapStateName: SwapStateName): boolean {
|
||||
return [SwapStateName.BtcRedeemed].includes(swapStateName);
|
||||
}
|
||||
|
||||
// See https://github.com/comit-network/xmr-btc-swap/blob/50ae54141255e03dba3d2b09036b1caa4a63e5a3/swap/src/protocol/bob/state.rs#L55
|
||||
export function getHumanReadableDbStateType(type: SwapStateName): string {
|
||||
switch (type) {
|
||||
case SwapStateName.Started:
|
||||
return 'Quote has been requested';
|
||||
case SwapStateName.SwapSetupCompleted:
|
||||
return 'Swap has been initiated';
|
||||
case SwapStateName.BtcLocked:
|
||||
return 'Bitcoin has been locked';
|
||||
case SwapStateName.XmrLockProofReceived:
|
||||
return 'Monero lock transaction transfer proof has been received';
|
||||
case SwapStateName.XmrLocked:
|
||||
return 'Monero has been locked';
|
||||
case SwapStateName.EncSigSent:
|
||||
return 'Encrypted signature has been sent';
|
||||
case SwapStateName.BtcRedeemed:
|
||||
return 'Bitcoin has been redeemed';
|
||||
case SwapStateName.CancelTimelockExpired:
|
||||
return 'Cancel timelock has expired';
|
||||
case SwapStateName.BtcCancelled:
|
||||
return 'Swap has been cancelled';
|
||||
case SwapStateName.BtcRefunded:
|
||||
return 'Bitcoin has been refunded';
|
||||
case SwapStateName.XmrRedeemed:
|
||||
return 'Monero has been redeemed';
|
||||
case SwapStateName.BtcPunished:
|
||||
return 'Bitcoin has been punished';
|
||||
case SwapStateName.SafelyAborted:
|
||||
return 'Swap has been safely aborted';
|
||||
default:
|
||||
return exhaustiveGuard(type);
|
||||
}
|
||||
}
|
||||
|
||||
export function getSwapTxFees(swap: GetSwapInfoResponse): number {
|
||||
return satsToBtc(swap.txLockFee);
|
||||
}
|
||||
|
||||
export function getSwapBtcAmount(swap: GetSwapInfoResponse): number {
|
||||
return satsToBtc(swap.btcAmount);
|
||||
}
|
||||
|
||||
export function getSwapXmrAmount(swap: GetSwapInfoResponse): number {
|
||||
return piconerosToXmr(swap.xmrAmount);
|
||||
}
|
||||
|
||||
export function getSwapExchangeRate(swap: GetSwapInfoResponse): number {
|
||||
const btcAmount = getSwapBtcAmount(swap);
|
||||
const xmrAmount = getSwapXmrAmount(swap);
|
||||
|
||||
return btcAmount / xmrAmount;
|
||||
}
|
218
src/models/storeModel.ts
Normal file
@ -0,0 +1,218 @@
|
||||
import { CliLog, SwapSpawnType } from './cliModel';
|
||||
import { Provider } from './apiModel';
|
||||
|
||||
export interface SwapSlice {
|
||||
state: SwapState | null;
|
||||
logs: CliLog[];
|
||||
processRunning: boolean;
|
||||
provider: Provider | null;
|
||||
spawnType: SwapSpawnType | null;
|
||||
swapId: string | null;
|
||||
}
|
||||
|
||||
export type MoneroWalletRpcUpdateState = {
|
||||
progress: string;
|
||||
downloadUrl: string;
|
||||
};
|
||||
|
||||
export interface SwapState {
|
||||
type: SwapStateType;
|
||||
}
|
||||
|
||||
export enum SwapStateType {
|
||||
INITIATED = 'initiated',
|
||||
RECEIVED_QUOTE = 'received quote',
|
||||
WAITING_FOR_BTC_DEPOSIT = 'waiting for btc deposit',
|
||||
STARTED = 'started',
|
||||
BTC_LOCK_TX_IN_MEMPOOL = 'btc lock tx is in mempool',
|
||||
XMR_LOCK_TX_IN_MEMPOOL = 'xmr lock tx is in mempool',
|
||||
XMR_LOCKED = 'xmr is locked',
|
||||
BTC_REDEEMED = 'btc redeemed',
|
||||
XMR_REDEEM_IN_MEMPOOL = 'xmr redeem tx is in mempool',
|
||||
PROCESS_EXITED = 'process exited',
|
||||
BTC_CANCELLED = 'btc cancelled',
|
||||
BTC_REFUNDED = 'btc refunded',
|
||||
BTC_PUNISHED = 'btc punished',
|
||||
ATTEMPTING_COOPERATIVE_REDEEM = 'attempting cooperative redeem',
|
||||
COOPERATIVE_REDEEM_REJECTED = 'cooperative redeem rejected',
|
||||
}
|
||||
|
||||
export function isSwapState(state?: SwapState | null): state is SwapState {
|
||||
return state?.type != null;
|
||||
}
|
||||
|
||||
export interface SwapStateInitiated extends SwapState {
|
||||
type: SwapStateType.INITIATED;
|
||||
}
|
||||
|
||||
export function isSwapStateInitiated(
|
||||
state?: SwapState | null,
|
||||
): state is SwapStateInitiated {
|
||||
return state?.type === SwapStateType.INITIATED;
|
||||
}
|
||||
|
||||
export interface SwapStateReceivedQuote extends SwapState {
|
||||
type: SwapStateType.RECEIVED_QUOTE;
|
||||
price: number;
|
||||
minimumSwapAmount: number;
|
||||
maximumSwapAmount: number;
|
||||
}
|
||||
|
||||
export function isSwapStateReceivedQuote(
|
||||
state?: SwapState | null,
|
||||
): state is SwapStateReceivedQuote {
|
||||
return state?.type === SwapStateType.RECEIVED_QUOTE;
|
||||
}
|
||||
|
||||
export interface SwapStateWaitingForBtcDeposit extends SwapState {
|
||||
type: SwapStateType.WAITING_FOR_BTC_DEPOSIT;
|
||||
depositAddress: string;
|
||||
maxGiveable: number;
|
||||
minimumAmount: number;
|
||||
maximumAmount: number;
|
||||
minDeposit: number;
|
||||
maxDeposit: number;
|
||||
minBitcoinLockTxFee: number;
|
||||
price: number | null;
|
||||
}
|
||||
|
||||
export function isSwapStateWaitingForBtcDeposit(
|
||||
state?: SwapState | null,
|
||||
): state is SwapStateWaitingForBtcDeposit {
|
||||
return state?.type === SwapStateType.WAITING_FOR_BTC_DEPOSIT;
|
||||
}
|
||||
|
||||
export interface SwapStateStarted extends SwapState {
|
||||
type: SwapStateType.STARTED;
|
||||
txLockDetails: {
|
||||
amount: number;
|
||||
fees: number;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export function isSwapStateStarted(
|
||||
state?: SwapState | null,
|
||||
): state is SwapStateStarted {
|
||||
return state?.type === SwapStateType.STARTED;
|
||||
}
|
||||
|
||||
export interface SwapStateBtcLockInMempool extends SwapState {
|
||||
type: SwapStateType.BTC_LOCK_TX_IN_MEMPOOL;
|
||||
bobBtcLockTxId: string;
|
||||
bobBtcLockTxConfirmations: number;
|
||||
}
|
||||
|
||||
export function isSwapStateBtcLockInMempool(
|
||||
state?: SwapState | null,
|
||||
): state is SwapStateBtcLockInMempool {
|
||||
return state?.type === SwapStateType.BTC_LOCK_TX_IN_MEMPOOL;
|
||||
}
|
||||
|
||||
export interface SwapStateXmrLockInMempool extends SwapState {
|
||||
type: SwapStateType.XMR_LOCK_TX_IN_MEMPOOL;
|
||||
aliceXmrLockTxId: string;
|
||||
aliceXmrLockTxConfirmations: number;
|
||||
}
|
||||
|
||||
export function isSwapStateXmrLockInMempool(
|
||||
state?: SwapState | null,
|
||||
): state is SwapStateXmrLockInMempool {
|
||||
return state?.type === SwapStateType.XMR_LOCK_TX_IN_MEMPOOL;
|
||||
}
|
||||
|
||||
export interface SwapStateXmrLocked extends SwapState {
|
||||
type: SwapStateType.XMR_LOCKED;
|
||||
}
|
||||
|
||||
export function isSwapStateXmrLocked(
|
||||
state?: SwapState | null,
|
||||
): state is SwapStateXmrLocked {
|
||||
return state?.type === SwapStateType.XMR_LOCKED;
|
||||
}
|
||||
|
||||
export interface SwapStateBtcRedemeed extends SwapState {
|
||||
type: SwapStateType.BTC_REDEEMED;
|
||||
}
|
||||
|
||||
export function isSwapStateBtcRedemeed(
|
||||
state?: SwapState | null,
|
||||
): state is SwapStateBtcRedemeed {
|
||||
return state?.type === SwapStateType.BTC_REDEEMED;
|
||||
}
|
||||
|
||||
export interface SwapStateAttemptingCooperativeRedeeem extends SwapState {
|
||||
type: SwapStateType.ATTEMPTING_COOPERATIVE_REDEEM;
|
||||
}
|
||||
|
||||
export function isSwapStateAttemptingCooperativeRedeeem(
|
||||
state?: SwapState | null,
|
||||
): state is SwapStateAttemptingCooperativeRedeeem {
|
||||
return state?.type === SwapStateType.ATTEMPTING_COOPERATIVE_REDEEM;
|
||||
}
|
||||
|
||||
export interface SwapStateCooperativeRedeemRejected extends SwapState {
|
||||
type: SwapStateType.COOPERATIVE_REDEEM_REJECTED;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export function isSwapStateCooperativeRedeemRejected(
|
||||
state?: SwapState | null,
|
||||
): state is SwapStateCooperativeRedeemRejected {
|
||||
return state?.type === SwapStateType.COOPERATIVE_REDEEM_REJECTED;
|
||||
}
|
||||
|
||||
export interface SwapStateXmrRedeemInMempool extends SwapState {
|
||||
type: SwapStateType.XMR_REDEEM_IN_MEMPOOL;
|
||||
bobXmrRedeemTxId: string;
|
||||
bobXmrRedeemAddress: string;
|
||||
}
|
||||
|
||||
export function isSwapStateXmrRedeemInMempool(
|
||||
state?: SwapState | null,
|
||||
): state is SwapStateXmrRedeemInMempool {
|
||||
return state?.type === SwapStateType.XMR_REDEEM_IN_MEMPOOL;
|
||||
}
|
||||
|
||||
export interface SwapStateBtcCancelled extends SwapState {
|
||||
type: SwapStateType.BTC_CANCELLED;
|
||||
btcCancelTxId: string;
|
||||
}
|
||||
|
||||
export function isSwapStateBtcCancelled(
|
||||
state?: SwapState | null,
|
||||
): state is SwapStateBtcCancelled {
|
||||
return state?.type === SwapStateType.BTC_CANCELLED;
|
||||
}
|
||||
|
||||
export interface SwapStateBtcRefunded extends SwapState {
|
||||
type: SwapStateType.BTC_REFUNDED;
|
||||
bobBtcRefundTxId: string;
|
||||
}
|
||||
|
||||
export function isSwapStateBtcRefunded(
|
||||
state?: SwapState | null,
|
||||
): state is SwapStateBtcRefunded {
|
||||
return state?.type === SwapStateType.BTC_REFUNDED;
|
||||
}
|
||||
|
||||
export interface SwapStateBtcPunished extends SwapState {
|
||||
type: SwapStateType.BTC_PUNISHED;
|
||||
}
|
||||
|
||||
export function isSwapStateBtcPunished(
|
||||
state?: SwapState | null,
|
||||
): state is SwapStateBtcPunished {
|
||||
return state?.type === SwapStateType.BTC_PUNISHED;
|
||||
}
|
||||
|
||||
export interface SwapStateProcessExited extends SwapState {
|
||||
type: SwapStateType.PROCESS_EXITED;
|
||||
prevState: SwapState | null;
|
||||
rpcError: string | null;
|
||||
}
|
||||
|
||||
export function isSwapStateProcessExited(
|
||||
state?: SwapState | null,
|
||||
): state is SwapStateProcessExited {
|
||||
return state?.type === SwapStateType.PROCESS_EXITED;
|
||||
}
|
61
src/renderer/api.ts
Normal file
@ -0,0 +1,61 @@
|
||||
import { Alert, ExtendedProviderStatus } from 'models/apiModel';
|
||||
|
||||
const API_BASE_URL = 'https://api.unstoppableswap.net';
|
||||
|
||||
export async function fetchProvidersViaHttp(): Promise<
|
||||
ExtendedProviderStatus[]
|
||||
> {
|
||||
const response = await fetch(`${API_BASE_URL}/api/list`);
|
||||
return (await response.json()) as ExtendedProviderStatus[];
|
||||
}
|
||||
|
||||
export async function fetchAlertsViaHttp(): Promise<Alert[]> {
|
||||
const response = await fetch(`${API_BASE_URL}/api/alerts`);
|
||||
return (await response.json()) as Alert[];
|
||||
}
|
||||
|
||||
export async function submitFeedbackViaHttp(
|
||||
body: string,
|
||||
attachedData: string,
|
||||
): Promise<string> {
|
||||
type Response = {
|
||||
feedbackId: string;
|
||||
};
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}/api/submit-feedback`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ body, attachedData }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Status: ${response.status}`);
|
||||
}
|
||||
|
||||
const responseBody = (await response.json()) as Response;
|
||||
|
||||
return responseBody.feedbackId;
|
||||
}
|
||||
|
||||
async function fetchCurrencyUsdPrice(currency: string): Promise<number> {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`https://api.coingecko.com/api/v3/simple/price?ids=${currency}&vs_currencies=usd`,
|
||||
);
|
||||
const data = await response.json();
|
||||
return data[currency].usd;
|
||||
} catch (error) {
|
||||
console.error(`Error fetching ${currency} price:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchBtcPrice(): Promise<number> {
|
||||
return fetchCurrencyUsdPrice('bitcoin');
|
||||
}
|
||||
|
||||
export async function fetchXmrPrice(): Promise<number> {
|
||||
return fetchCurrencyUsdPrice('monero');
|
||||
}
|
67
src/renderer/components/App.tsx
Normal file
@ -0,0 +1,67 @@
|
||||
import { Box, makeStyles, CssBaseline } from '@material-ui/core';
|
||||
import { createTheme, ThemeProvider } from '@material-ui/core/styles';
|
||||
import { indigo } from '@material-ui/core/colors';
|
||||
import { MemoryRouter as Router, Routes, Route } from 'react-router-dom';
|
||||
import Navigation, { drawerWidth } from './navigation/Navigation';
|
||||
import HistoryPage from './pages/history/HistoryPage';
|
||||
import SwapPage from './pages/swap/SwapPage';
|
||||
import WalletPage from './pages/wallet/WalletPage';
|
||||
import HelpPage from './pages/help/HelpPage';
|
||||
import GlobalSnackbarProvider from './snackbar/GlobalSnackbarProvider';
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
innerContent: {
|
||||
padding: theme.spacing(4),
|
||||
marginLeft: drawerWidth,
|
||||
maxHeight: `100vh`,
|
||||
flex: 1,
|
||||
},
|
||||
}));
|
||||
|
||||
const theme = createTheme({
|
||||
palette: {
|
||||
type: 'dark',
|
||||
primary: {
|
||||
main: '#f4511e',
|
||||
},
|
||||
secondary: indigo,
|
||||
},
|
||||
transitions: {
|
||||
create: () => 'none',
|
||||
},
|
||||
props: {
|
||||
MuiButtonBase: {
|
||||
disableRipple: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
function InnerContent() {
|
||||
const classes = useStyles();
|
||||
|
||||
return (
|
||||
<Box className={classes.innerContent}>
|
||||
<Routes>
|
||||
<Route path="/swap" element={<SwapPage />} />
|
||||
<Route path="/history" element={<HistoryPage />} />
|
||||
<Route path="/wallet" element={<WalletPage />} />
|
||||
<Route path="/help" element={<HelpPage />} />
|
||||
<Route path="/" element={<SwapPage />} />
|
||||
</Routes>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<ThemeProvider theme={theme}>
|
||||
<GlobalSnackbarProvider>
|
||||
<CssBaseline />
|
||||
<Router>
|
||||
<Navigation />
|
||||
<InnerContent />
|
||||
</Router>
|
||||
</GlobalSnackbarProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
166
src/renderer/components/IpcInvokeButton.tsx
Normal file
@ -0,0 +1,166 @@
|
||||
import {
|
||||
Button,
|
||||
ButtonProps,
|
||||
CircularProgress,
|
||||
IconButton,
|
||||
Tooltip,
|
||||
} from '@material-ui/core';
|
||||
import { ReactElement, ReactNode, useEffect, useState } from 'react';
|
||||
import { useSnackbar } from 'notistack';
|
||||
import { useAppSelector } from 'store/hooks';
|
||||
import { RpcProcessStateType } from 'models/rpcModel';
|
||||
import { isExternalRpc } from 'store/config';
|
||||
|
||||
function IpcButtonTooltip({
|
||||
requiresRpcAndNotReady,
|
||||
children,
|
||||
processType,
|
||||
tooltipTitle,
|
||||
}: {
|
||||
requiresRpcAndNotReady: boolean;
|
||||
children: ReactElement;
|
||||
processType: RpcProcessStateType;
|
||||
tooltipTitle?: string;
|
||||
}) {
|
||||
if (tooltipTitle) {
|
||||
return <Tooltip title={tooltipTitle}>{children}</Tooltip>;
|
||||
}
|
||||
|
||||
const getMessage = () => {
|
||||
if (!requiresRpcAndNotReady) return '';
|
||||
|
||||
switch (processType) {
|
||||
case RpcProcessStateType.LISTENING_FOR_CONNECTIONS:
|
||||
return '';
|
||||
case RpcProcessStateType.STARTED:
|
||||
return 'Cannot execute this action because the Swap Daemon is still starting and not yet ready to accept connections. Please wait a moment and try again';
|
||||
case RpcProcessStateType.EXITED:
|
||||
return 'Cannot execute this action because the Swap Daemon has been stopped. Please start the Swap Daemon again to continue';
|
||||
case RpcProcessStateType.NOT_STARTED:
|
||||
return 'Cannot execute this action because the Swap Daemon has not been started yet. Please start the Swap Daemon first';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Tooltip title={getMessage()} color="red">
|
||||
{children}
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
interface IpcInvokeButtonProps<T> {
|
||||
ipcArgs: unknown[];
|
||||
ipcChannel: string;
|
||||
onSuccess?: (data: T) => void;
|
||||
isLoadingOverride?: boolean;
|
||||
isIconButton?: boolean;
|
||||
loadIcon?: ReactNode;
|
||||
requiresRpc?: boolean;
|
||||
disabled?: boolean;
|
||||
displayErrorSnackbar?: boolean;
|
||||
tooltipTitle?: string;
|
||||
}
|
||||
|
||||
const DELAY_BEFORE_SHOWING_LOADING_MS = 0;
|
||||
|
||||
export default function IpcInvokeButton<T>({
|
||||
disabled,
|
||||
ipcChannel,
|
||||
ipcArgs,
|
||||
onSuccess,
|
||||
onClick,
|
||||
endIcon,
|
||||
loadIcon,
|
||||
isLoadingOverride,
|
||||
isIconButton,
|
||||
requiresRpc,
|
||||
displayErrorSnackbar,
|
||||
tooltipTitle,
|
||||
...rest
|
||||
}: IpcInvokeButtonProps<T> & ButtonProps) {
|
||||
const { enqueueSnackbar } = useSnackbar();
|
||||
|
||||
const rpcProcessType = useAppSelector((state) => state.rpc.process.type);
|
||||
const isRpcReady =
|
||||
rpcProcessType === RpcProcessStateType.LISTENING_FOR_CONNECTIONS;
|
||||
const [isPending, setIsPending] = useState(false);
|
||||
const [hasMinLoadingTimePassed, setHasMinLoadingTimePassed] = useState(false);
|
||||
|
||||
const isLoading = (isPending && hasMinLoadingTimePassed) || isLoadingOverride;
|
||||
const actualEndIcon = isLoading
|
||||
? loadIcon || <CircularProgress size="1rem" />
|
||||
: endIcon;
|
||||
|
||||
useEffect(() => {
|
||||
setHasMinLoadingTimePassed(false);
|
||||
setTimeout(
|
||||
() => setHasMinLoadingTimePassed(true),
|
||||
DELAY_BEFORE_SHOWING_LOADING_MS,
|
||||
);
|
||||
}, [isPending]);
|
||||
|
||||
async function handleClick(event: React.MouseEvent<HTMLButtonElement>) {
|
||||
onClick?.(event);
|
||||
|
||||
if (!isPending) {
|
||||
setIsPending(true);
|
||||
try {
|
||||
// const result = await ipcRenderer.invoke(ipcChannel, ...ipcArgs);
|
||||
throw new Error('Not implemented');
|
||||
// onSuccess?.(result);
|
||||
} catch (e: unknown) {
|
||||
if (displayErrorSnackbar) {
|
||||
enqueueSnackbar((e as Error).message, {
|
||||
autoHideDuration: 60 * 1000,
|
||||
variant: 'error',
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
setIsPending(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const requiresRpcAndNotReady =
|
||||
!!requiresRpc && !isRpcReady && !isExternalRpc();
|
||||
const isDisabled = disabled || requiresRpcAndNotReady || isLoading;
|
||||
|
||||
return (
|
||||
<IpcButtonTooltip
|
||||
requiresRpcAndNotReady={requiresRpcAndNotReady}
|
||||
processType={rpcProcessType}
|
||||
tooltipTitle={tooltipTitle}
|
||||
>
|
||||
<span>
|
||||
{isIconButton ? (
|
||||
<IconButton
|
||||
onClick={handleClick}
|
||||
disabled={isDisabled}
|
||||
{...(rest as any)}
|
||||
>
|
||||
{actualEndIcon}
|
||||
</IconButton>
|
||||
) : (
|
||||
<Button
|
||||
onClick={handleClick}
|
||||
disabled={isDisabled}
|
||||
endIcon={actualEndIcon}
|
||||
{...rest}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
</IpcButtonTooltip>
|
||||
);
|
||||
}
|
||||
|
||||
IpcInvokeButton.defaultProps = {
|
||||
requiresRpc: true,
|
||||
disabled: false,
|
||||
onSuccess: undefined,
|
||||
isLoadingOverride: false,
|
||||
isIconButton: false,
|
||||
loadIcon: undefined,
|
||||
displayErrorSnackbar: true,
|
||||
};
|
30
src/renderer/components/alert/FundsLeftInWalletAlert.tsx
Normal file
@ -0,0 +1,30 @@
|
||||
import { Button } from '@material-ui/core';
|
||||
import Alert from '@material-ui/lab/Alert';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAppSelector } from 'store/hooks';
|
||||
|
||||
export default function FundsLeftInWalletAlert() {
|
||||
const fundsLeft = useAppSelector((state) => state.rpc.state.balance);
|
||||
const navigate = useNavigate();
|
||||
|
||||
if (fundsLeft != null && fundsLeft > 0) {
|
||||
return (
|
||||
<Alert
|
||||
variant="filled"
|
||||
severity="info"
|
||||
action={
|
||||
<Button
|
||||
color="inherit"
|
||||
size="small"
|
||||
onClick={() => navigate('/wallet')}
|
||||
>
|
||||
View
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
There are some Bitcoin left in your wallet
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
@ -0,0 +1,30 @@
|
||||
import { Alert } from '@material-ui/lab';
|
||||
import { Box, LinearProgress } from '@material-ui/core';
|
||||
import { useAppSelector } from 'store/hooks';
|
||||
|
||||
export default function MoneroWalletRpcUpdatingAlert() {
|
||||
const updateState = useAppSelector(
|
||||
(s) => s.rpc.state.moneroWalletRpc.updateState,
|
||||
);
|
||||
|
||||
if (updateState === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const progress = Number.parseFloat(
|
||||
updateState.progress.substring(0, updateState.progress.length - 1),
|
||||
);
|
||||
|
||||
return (
|
||||
<Alert severity="info">
|
||||
<Box style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
|
||||
<span>The Monero wallet is updating. This may take a few moments</span>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={progress}
|
||||
title="Download progress"
|
||||
/>
|
||||
</Box>
|
||||
</Alert>
|
||||
);
|
||||
}
|
@ -0,0 +1,35 @@
|
||||
import { Alert } from '@material-ui/lab';
|
||||
import { Box, makeStyles } from '@material-ui/core';
|
||||
import { useAppSelector } from 'store/hooks';
|
||||
import WalletRefreshButton from '../pages/wallet/WalletRefreshButton';
|
||||
import { SatsAmount } from '../other/Units';
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
outer: {
|
||||
paddingBottom: theme.spacing(1),
|
||||
},
|
||||
}));
|
||||
|
||||
export default function RemainingFundsWillBeUsedAlert() {
|
||||
const classes = useStyles();
|
||||
const balance = useAppSelector((s) => s.rpc.state.balance);
|
||||
|
||||
if (balance == null || balance <= 0) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box className={classes.outer}>
|
||||
<Alert
|
||||
severity="warning"
|
||||
action={<WalletRefreshButton />}
|
||||
variant="filled"
|
||||
>
|
||||
The remaining funds of <SatsAmount amount={balance} /> in the wallet
|
||||
will be used for the next swap. If the remaining funds exceed the
|
||||
minimum swap amount of the provider, a swap will be initiated
|
||||
instantaneously.
|
||||
</Alert>
|
||||
</Box>
|
||||
);
|
||||
}
|
27
src/renderer/components/alert/RpcStatusAlert.tsx
Normal file
@ -0,0 +1,27 @@
|
||||
import { Alert } from '@material-ui/lab';
|
||||
import { CircularProgress } from '@material-ui/core';
|
||||
import { useAppSelector } from 'store/hooks';
|
||||
import { RpcProcessStateType } from 'models/rpcModel';
|
||||
|
||||
export default function RpcStatusAlert() {
|
||||
const rpcProcess = useAppSelector((s) => s.rpc.process);
|
||||
if (rpcProcess.type === RpcProcessStateType.STARTED) {
|
||||
return (
|
||||
<Alert severity="warning" icon={<CircularProgress size={22} />}>
|
||||
The swap daemon is starting
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
if (rpcProcess.type === RpcProcessStateType.LISTENING_FOR_CONNECTIONS) {
|
||||
return <Alert severity="success">The swap daemon is running</Alert>;
|
||||
}
|
||||
if (rpcProcess.type === RpcProcessStateType.NOT_STARTED) {
|
||||
return <Alert severity="warning">The swap daemon is being started</Alert>;
|
||||
}
|
||||
if (rpcProcess.type === RpcProcessStateType.EXITED) {
|
||||
return (
|
||||
<Alert severity="error">The swap daemon has stopped unexpectedly</Alert>
|
||||
);
|
||||
}
|
||||
return <></>;
|
||||
}
|
97
src/renderer/components/alert/SwapMightBeCancelledAlert.tsx
Normal file
@ -0,0 +1,97 @@
|
||||
import { makeStyles } from '@material-ui/core';
|
||||
import { Alert, AlertTitle } from '@material-ui/lab';
|
||||
import { useActiveSwapInfo } from 'store/hooks';
|
||||
import {
|
||||
isSwapTimelockInfoCancelled,
|
||||
isSwapTimelockInfoNone,
|
||||
} from 'models/rpcModel';
|
||||
import HumanizedBitcoinBlockDuration from '../other/HumanizedBitcoinBlockDuration';
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
outer: {
|
||||
marginBottom: theme.spacing(1),
|
||||
},
|
||||
list: {
|
||||
margin: theme.spacing(0.25),
|
||||
},
|
||||
}));
|
||||
|
||||
export default function SwapMightBeCancelledAlert({
|
||||
bobBtcLockTxConfirmations,
|
||||
}: {
|
||||
bobBtcLockTxConfirmations: number;
|
||||
}) {
|
||||
const classes = useStyles();
|
||||
const swap = useActiveSwapInfo();
|
||||
|
||||
if (
|
||||
bobBtcLockTxConfirmations < 5 ||
|
||||
swap === null ||
|
||||
swap.timelock === null
|
||||
) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
const { timelock } = swap;
|
||||
const punishTimelockOffset = swap.punishTimelock;
|
||||
|
||||
return (
|
||||
<Alert severity="warning" className={classes.outer} variant="filled">
|
||||
<AlertTitle>Be careful!</AlertTitle>
|
||||
The swap provider has taken a long time to lock their Monero. This might
|
||||
mean that:
|
||||
<ul className={classes.list}>
|
||||
<li>
|
||||
There is a technical issue that prevents them from locking their funds
|
||||
</li>
|
||||
<li>They are a malicious actor (unlikely)</li>
|
||||
</ul>
|
||||
<br />
|
||||
There is still hope for the swap to be successful but you have to be extra
|
||||
careful. Regardless of why it has taken them so long, it is important that
|
||||
you refund the swap within the required time period if the swap is not
|
||||
completed. If you fail to to do so, you will be punished and lose your
|
||||
money.
|
||||
<ul className={classes.list}>
|
||||
{isSwapTimelockInfoNone(timelock) && (
|
||||
<>
|
||||
<li>
|
||||
<strong>
|
||||
You will be able to refund in about{' '}
|
||||
<HumanizedBitcoinBlockDuration
|
||||
blocks={timelock.None.blocks_left}
|
||||
/>
|
||||
</strong>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<strong>
|
||||
If you have not refunded or completed the swap in about{' '}
|
||||
<HumanizedBitcoinBlockDuration
|
||||
blocks={timelock.None.blocks_left + punishTimelockOffset}
|
||||
/>
|
||||
, you will lose your funds.
|
||||
</strong>
|
||||
</li>
|
||||
</>
|
||||
)}
|
||||
{isSwapTimelockInfoCancelled(timelock) && (
|
||||
<li>
|
||||
<strong>
|
||||
If you have not refunded or completed the swap in about{' '}
|
||||
<HumanizedBitcoinBlockDuration
|
||||
blocks={timelock.Cancel.blocks_left}
|
||||
/>
|
||||
, you will lose your funds.
|
||||
</strong>
|
||||
</li>
|
||||
)}
|
||||
<li>
|
||||
As long as you see this screen, the swap will be refunded
|
||||
automatically when the time comes. If this fails, you have to manually
|
||||
refund by navigating to the History page.
|
||||
</li>
|
||||
</ul>
|
||||
</Alert>
|
||||
);
|
||||
}
|
233
src/renderer/components/alert/SwapStatusAlert.tsx
Normal file
@ -0,0 +1,233 @@
|
||||
import { Alert, AlertTitle } from '@material-ui/lab/';
|
||||
import { Box, makeStyles } from '@material-ui/core';
|
||||
import { ReactNode } from 'react';
|
||||
import { exhaustiveGuard } from 'utils/typescriptUtils';
|
||||
import {
|
||||
SwapCancelRefundButton,
|
||||
SwapResumeButton,
|
||||
} from '../pages/history/table/HistoryRowActions';
|
||||
import HumanizedBitcoinBlockDuration from '../other/HumanizedBitcoinBlockDuration';
|
||||
import {
|
||||
GetSwapInfoResponse,
|
||||
GetSwapInfoResponseRunningSwap,
|
||||
isGetSwapInfoResponseRunningSwap,
|
||||
isSwapTimelockInfoCancelled,
|
||||
isSwapTimelockInfoNone,
|
||||
isSwapTimelockInfoPunished,
|
||||
SwapStateName,
|
||||
SwapTimelockInfoCancelled,
|
||||
SwapTimelockInfoNone,
|
||||
} from '../../../models/rpcModel';
|
||||
import { SwapMoneroRecoveryButton } from '../pages/history/table/SwapMoneroRecoveryButton';
|
||||
|
||||
const useStyles = makeStyles({
|
||||
box: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '0.5rem',
|
||||
},
|
||||
list: {
|
||||
padding: '0px',
|
||||
margin: '0px',
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Component for displaying a list of messages.
|
||||
* @param messages - Array of messages to display.
|
||||
* @returns JSX.Element
|
||||
*/
|
||||
const MessageList = ({ messages }: { messages: ReactNode[] }) => {
|
||||
const classes = useStyles();
|
||||
return (
|
||||
<ul className={classes.list}>
|
||||
{messages.map((msg, i) => (
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
<li key={i}>{msg}</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Sub-component for displaying alerts when the swap is in a safe state.
|
||||
* @param swap - The swap information.
|
||||
* @returns JSX.Element
|
||||
*/
|
||||
const BitcoinRedeemedStateAlert = ({ swap }: { swap: GetSwapInfoResponse }) => {
|
||||
const classes = useStyles();
|
||||
return (
|
||||
<Box className={classes.box}>
|
||||
<MessageList
|
||||
messages={[
|
||||
'The Bitcoin has been redeemed by the other party',
|
||||
'There is no risk of losing funds. You can take your time',
|
||||
'The Monero will be automatically redeemed to the address you provided as soon as you resume the swap',
|
||||
'If this step fails, you can manually redeem the funds',
|
||||
]}
|
||||
/>
|
||||
<SwapMoneroRecoveryButton swap={swap} size="small" variant="contained" />
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Sub-component for displaying alerts when the swap is in a state with no timelock info.
|
||||
* @param swap - The swap information.
|
||||
* @param punishTimelockOffset - The punish timelock offset.
|
||||
* @returns JSX.Element
|
||||
*/
|
||||
const BitcoinLockedNoTimelockExpiredStateAlert = ({
|
||||
timelock,
|
||||
punishTimelockOffset,
|
||||
}: {
|
||||
timelock: SwapTimelockInfoNone;
|
||||
punishTimelockOffset: number;
|
||||
}) => (
|
||||
<MessageList
|
||||
messages={[
|
||||
<>
|
||||
Your Bitcoin is locked. If the swap is not completed in approximately{' '}
|
||||
<HumanizedBitcoinBlockDuration blocks={timelock.None.blocks_left} />,
|
||||
you need to refund
|
||||
</>,
|
||||
<>
|
||||
You will lose your funds if you do not refund or complete the swap
|
||||
within{' '}
|
||||
<HumanizedBitcoinBlockDuration
|
||||
blocks={timelock.None.blocks_left + punishTimelockOffset}
|
||||
/>
|
||||
</>,
|
||||
]}
|
||||
/>
|
||||
);
|
||||
|
||||
/**
|
||||
* Sub-component for displaying alerts when the swap timelock is expired
|
||||
* The swap could be cancelled but not necessarily (the transaction might not have been published yet)
|
||||
* But it doesn't matter because the swap cannot be completed anymore
|
||||
* @param swap - The swap information.
|
||||
* @returns JSX.Element
|
||||
*/
|
||||
const BitcoinPossiblyCancelledAlert = ({
|
||||
swap,
|
||||
timelock,
|
||||
}: {
|
||||
swap: GetSwapInfoResponse;
|
||||
timelock: SwapTimelockInfoCancelled;
|
||||
}) => {
|
||||
const classes = useStyles();
|
||||
return (
|
||||
<Box className={classes.box}>
|
||||
<MessageList
|
||||
messages={[
|
||||
'The swap was cancelled because it did not complete in time',
|
||||
'You must resume the swap immediately to refund your Bitcoin. If that fails, you can manually refund it',
|
||||
<>
|
||||
You will lose your funds if you do not refund within{' '}
|
||||
<HumanizedBitcoinBlockDuration
|
||||
blocks={timelock.Cancel.blocks_left}
|
||||
/>
|
||||
</>,
|
||||
]}
|
||||
/>
|
||||
<SwapCancelRefundButton swap={swap} size="small" variant="contained" />
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Sub-component for displaying alerts requiring immediate action.
|
||||
* @returns JSX.Element
|
||||
*/
|
||||
const ImmediateActionAlert = () => (
|
||||
<>Resume the swap immediately to avoid losing your funds</>
|
||||
);
|
||||
|
||||
/**
|
||||
* Main component for displaying the appropriate swap alert status text.
|
||||
* @param swap - The swap information.
|
||||
* @returns JSX.Element | null
|
||||
*/
|
||||
function SwapAlertStatusText({
|
||||
swap,
|
||||
}: {
|
||||
swap: GetSwapInfoResponseRunningSwap;
|
||||
}) {
|
||||
switch (swap.stateName) {
|
||||
// This is the state where the swap is safe because the other party has redeemed the Bitcoin
|
||||
// It cannot be punished anymore
|
||||
case SwapStateName.BtcRedeemed:
|
||||
return <BitcoinRedeemedStateAlert swap={swap} />;
|
||||
|
||||
// These are states that are at risk of punishment because the Bitcoin have been locked
|
||||
// but has not been redeemed yet by the other party
|
||||
case SwapStateName.BtcLocked:
|
||||
case SwapStateName.XmrLockProofReceived:
|
||||
case SwapStateName.XmrLocked:
|
||||
case SwapStateName.EncSigSent:
|
||||
case SwapStateName.CancelTimelockExpired:
|
||||
case SwapStateName.BtcCancelled:
|
||||
if (swap.timelock !== null) {
|
||||
if (isSwapTimelockInfoNone(swap.timelock)) {
|
||||
return (
|
||||
<BitcoinLockedNoTimelockExpiredStateAlert
|
||||
punishTimelockOffset={swap.punishTimelock}
|
||||
timelock={swap.timelock}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (isSwapTimelockInfoCancelled(swap.timelock)) {
|
||||
return (
|
||||
<BitcoinPossiblyCancelledAlert
|
||||
timelock={swap.timelock}
|
||||
swap={swap}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (isSwapTimelockInfoPunished(swap.timelock)) {
|
||||
return <ImmediateActionAlert />;
|
||||
}
|
||||
|
||||
// We have covered all possible timelock states above
|
||||
// If we reach this point, it means we have missed a case
|
||||
return exhaustiveGuard(swap.timelock);
|
||||
}
|
||||
return <ImmediateActionAlert />;
|
||||
default:
|
||||
return exhaustiveGuard(swap.stateName);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Main component for displaying the swap status alert.
|
||||
* @param swap - The swap information.
|
||||
* @returns JSX.Element | null
|
||||
*/
|
||||
export default function SwapStatusAlert({
|
||||
swap,
|
||||
}: {
|
||||
swap: GetSwapInfoResponse;
|
||||
}): JSX.Element | null {
|
||||
// If the swap is not running, there is no need to display the alert
|
||||
// This is either because the swap is finished or has not started yet (e.g. in the setup phase, no Bitcoin locked)
|
||||
if (!isGetSwapInfoResponseRunningSwap(swap)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Alert
|
||||
key={swap.swapId}
|
||||
severity="warning"
|
||||
action={<SwapResumeButton swap={swap} />}
|
||||
variant="filled"
|
||||
>
|
||||
<AlertTitle>
|
||||
Swap {swap.swapId.substring(0, 5)}... is unfinished
|
||||
</AlertTitle>
|
||||
<SwapAlertStatusText swap={swap} />
|
||||
</Alert>
|
||||
);
|
||||
}
|
28
src/renderer/components/alert/SwapTxLockAlertsBox.tsx
Normal file
@ -0,0 +1,28 @@
|
||||
import { Box, makeStyles } from '@material-ui/core';
|
||||
import { useSwapInfosSortedByDate } from 'store/hooks';
|
||||
import SwapStatusAlert from './SwapStatusAlert';
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
outer: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: theme.spacing(1),
|
||||
},
|
||||
}));
|
||||
|
||||
export default function SwapTxLockAlertsBox() {
|
||||
const classes = useStyles();
|
||||
|
||||
// We specifically choose ALL swaps here
|
||||
// If a swap is in a state where an Alert is not needed (becaue no Bitcoin have been locked or because the swap has been completed)
|
||||
// the SwapStatusAlert component will not render an Alert
|
||||
const swaps = useSwapInfosSortedByDate();
|
||||
|
||||
return (
|
||||
<Box className={classes.outer}>
|
||||
{swaps.map((swap) => (
|
||||
<SwapStatusAlert key={swap.swapId} swap={swap} />
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
33
src/renderer/components/alert/UnfinishedSwapsAlert.tsx
Normal file
@ -0,0 +1,33 @@
|
||||
import { Button } from '@material-ui/core';
|
||||
import Alert from '@material-ui/lab/Alert';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useResumeableSwapsCount } from 'store/hooks';
|
||||
|
||||
export default function UnfinishedSwapsAlert() {
|
||||
const resumableSwapsCount = useResumeableSwapsCount();
|
||||
const navigate = useNavigate();
|
||||
|
||||
if (resumableSwapsCount > 0) {
|
||||
return (
|
||||
<Alert
|
||||
severity="warning"
|
||||
variant="filled"
|
||||
action={
|
||||
<Button
|
||||
color="inherit"
|
||||
size="small"
|
||||
onClick={() => navigate('/history')}
|
||||
>
|
||||
VIEW
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
You have{' '}
|
||||
{resumableSwapsCount > 1
|
||||
? `${resumableSwapsCount} unfinished swaps`
|
||||
: 'one unfinished swap'}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
24
src/renderer/components/icons/BitcoinIcon.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
import { SvgIcon } from '@material-ui/core';
|
||||
import { SvgIconProps } from '@material-ui/core/SvgIcon/SvgIcon';
|
||||
|
||||
export default function BitcoinIcon(props: SvgIconProps) {
|
||||
return (
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||
<SvgIcon {...props}>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
role="img"
|
||||
width="1em"
|
||||
height="1em"
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
d="M14.24 10.56c-.31 1.24-2.24.61-2.84.44l.55-2.18c.62.18 2.61.44 2.29 1.74m-3.11 1.56l-.6 2.41c.74.19 3.03.92 3.37-.44c.36-1.42-2.03-1.79-2.77-1.97m10.57 2.3c-1.34 5.36-6.76 8.62-12.12 7.28C4.22 20.36.963 14.94 2.3 9.58A9.996 9.996 0 0 1 14.42 2.3c5.35 1.34 8.61 6.76 7.28 12.12m-7.49-6.37l.45-1.8l-1.1-.25l-.44 1.73c-.29-.07-.58-.14-.88-.2l.44-1.77l-1.09-.26l-.45 1.79c-.24-.06-.48-.11-.7-.17l-1.51-.38l-.3 1.17s.82.19.8.2c.45.11.53.39.51.64l-1.23 4.93c-.05.14-.21.32-.5.27c.01.01-.8-.2-.8-.2L6.87 15l1.42.36c.27.07.53.14.79.2l-.46 1.82l1.1.28l.45-1.81c.3.08.59.15.87.23l-.45 1.79l1.1.28l.46-1.82c1.85.35 3.27.21 3.85-1.48c.5-1.35 0-2.15-1-2.66c.72-.19 1.26-.64 1.41-1.62c.2-1.33-.82-2.04-2.2-2.52z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
</SvgIcon>
|
||||
);
|
||||
}
|
24
src/renderer/components/icons/DiscordIcon.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
import { SvgIconProps } from '@material-ui/core/SvgIcon/SvgIcon';
|
||||
import { SvgIcon } from '@material-ui/core';
|
||||
|
||||
export default function DiscordIcon(props: SvgIconProps) {
|
||||
return (
|
||||
<SvgIcon {...props}>
|
||||
<svg
|
||||
width="1em"
|
||||
height="1em"
|
||||
viewBox="0 -28.5 256 256"
|
||||
version="1.1"
|
||||
role="img"
|
||||
preserveAspectRatio="xMidYMid"
|
||||
>
|
||||
<g>
|
||||
<path
|
||||
d="M216.856339,16.5966031 C200.285002,8.84328665 182.566144,3.2084988 164.041564,0 C161.766523,4.11318106 159.108624,9.64549908 157.276099,14.0464379 C137.583995,11.0849896 118.072967,11.0849896 98.7430163,14.0464379 C96.9108417,9.64549908 94.1925838,4.11318106 91.8971895,0 C73.3526068,3.2084988 55.6133949,8.86399117 39.0420583,16.6376612 C5.61752293,67.146514 -3.4433191,116.400813 1.08711069,164.955721 C23.2560196,181.510915 44.7403634,191.567697 65.8621325,198.148576 C71.0772151,190.971126 75.7283628,183.341335 79.7352139,175.300261 C72.104019,172.400575 64.7949724,168.822202 57.8887866,164.667963 C59.7209612,163.310589 61.5131304,161.891452 63.2445898,160.431257 C105.36741,180.133187 151.134928,180.133187 192.754523,160.431257 C194.506336,161.891452 196.298154,163.310589 198.110326,164.667963 C191.183787,168.842556 183.854737,172.420929 176.223542,175.320965 C180.230393,183.341335 184.861538,190.991831 190.096624,198.16893 C211.238746,191.588051 232.743023,181.531619 254.911949,164.955721 C260.227747,108.668201 245.831087,59.8662432 216.856339,16.5966031 Z M85.4738752,135.09489 C72.8290281,135.09489 62.4592217,123.290155 62.4592217,108.914901 C62.4592217,94.5396472 72.607595,82.7145587 85.4738752,82.7145587 C98.3405064,82.7145587 108.709962,94.5189427 108.488529,108.914901 C108.508531,123.290155 98.3405064,135.09489 85.4738752,135.09489 Z M170.525237,135.09489 C157.88039,135.09489 147.510584,123.290155 147.510584,108.914901 C147.510584,94.5396472 157.658606,82.7145587 170.525237,82.7145587 C183.391518,82.7145587 193.761324,94.5189427 193.539891,108.914901 C193.539891,123.290155 183.391518,135.09489 170.525237,135.09489 Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
</SvgIcon>
|
||||
);
|
||||
}
|
16
src/renderer/components/icons/LinkIconButton.tsx
Normal file
@ -0,0 +1,16 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { IconButton } from '@material-ui/core';
|
||||
|
||||
export default function LinkIconButton({
|
||||
url,
|
||||
children,
|
||||
}: {
|
||||
url: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<IconButton component="span" onClick={() => window.open(url, '_blank')}>
|
||||
{children}
|
||||
</IconButton>
|
||||
);
|
||||
}
|
28
src/renderer/components/icons/MoneroIcon.tsx
Normal file
@ -0,0 +1,28 @@
|
||||
import { SvgIcon } from '@material-ui/core';
|
||||
import { SvgIconProps } from '@material-ui/core/SvgIcon/SvgIcon';
|
||||
|
||||
export default function MoneroIcon(props: SvgIconProps) {
|
||||
return (
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||
<SvgIcon {...props}>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
role="img"
|
||||
width="1em"
|
||||
height="1em"
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
viewBox="0 0 256 256"
|
||||
>
|
||||
<path
|
||||
d="M127.998 0C57.318 0 0 57.317 0 127.999c0 14.127 2.29 27.716 6.518 40.43H44.8V60.733l83.2 83.2l83.198-83.2v107.695h38.282c4.231-12.714 6.521-26.303 6.521-40.43C256 57.314 198.681 0 127.998 0"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M108.867 163.062l-36.31-36.311v67.765H18.623c22.47 36.863 63.051 61.48 109.373 61.48s86.907-24.617 109.374-61.48h-53.933V126.75l-36.31 36.31l-19.13 19.129l-19.128-19.128h-.002z"
|
||||
fill="#4C4C4C"
|
||||
/>
|
||||
</svg>
|
||||
</SvgIcon>
|
||||
);
|
||||
}
|
24
src/renderer/components/icons/TorIcon.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
import { SvgIcon } from '@material-ui/core';
|
||||
import { SvgIconProps } from '@material-ui/core/SvgIcon/SvgIcon';
|
||||
|
||||
export default function TorIcon(props: SvgIconProps) {
|
||||
return (
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||
<SvgIcon {...props}>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
role="img"
|
||||
width="1em"
|
||||
height="1em"
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
viewBox="0 0 512 512"
|
||||
>
|
||||
<path
|
||||
d="M256.525143,465.439707 L256.525143,434.406609 C354.826191,434.122748 434.420802,354.364917 434.420802,255.992903 C434.420802,157.627987 354.826191,77.8701558 256.525143,77.5862948 L256.525143,46.5531962 C371.964296,46.8441537 465.446804,140.489882 465.446804,255.992903 C465.446804,371.503022 371.964296,465.155846 256.525143,465.439707 Z M256.525143,356.820314 C311.970283,356.529356 356.8487,311.516106 356.8487,255.992903 C356.8487,200.476798 311.970283,155.463547 256.525143,155.17259 L256.525143,124.146588 C329.115485,124.430449 387.881799,183.338693 387.881799,255.992903 C387.881799,328.654211 329.115485,387.562455 256.525143,387.846316 L256.525143,356.820314 Z M256.525143,201.718689 C286.266674,202.00255 310.3026,226.180407 310.3026,255.992903 C310.3026,285.812497 286.266674,309.990353 256.525143,310.274214 L256.525143,201.718689 Z M0,255.992903 C0,397.384044 114.60886,512 256,512 C397.384044,512 512,397.384044 512,255.992903 C512,114.60886 397.384044,0 256,0 C114.60886,0 0,114.60886 0,255.992903 Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
</SvgIcon>
|
||||
);
|
||||
}
|
41
src/renderer/components/inputs/BitcoinAddressTextField.tsx
Normal file
@ -0,0 +1,41 @@
|
||||
import { useEffect } from 'react';
|
||||
import { TextField } from '@material-ui/core';
|
||||
import { TextFieldProps } from '@material-ui/core/TextField/TextField';
|
||||
import { isBtcAddressValid } from 'utils/conversionUtils';
|
||||
import { isTestnet } from 'store/config';
|
||||
|
||||
export default function BitcoinAddressTextField({
|
||||
address,
|
||||
onAddressChange,
|
||||
onAddressValidityChange,
|
||||
helperText,
|
||||
...props
|
||||
}: {
|
||||
address: string;
|
||||
onAddressChange: (address: string) => void;
|
||||
onAddressValidityChange: (valid: boolean) => void;
|
||||
helperText: string;
|
||||
} & TextFieldProps) {
|
||||
const placeholder = isTestnet() ? 'tb1q4aelwalu...' : 'bc18ociqZ9mZ...';
|
||||
const errorText = isBtcAddressValid(address, isTestnet())
|
||||
? null
|
||||
: `Only bech32 addresses are supported. They begin with "${
|
||||
isTestnet() ? 'tb1' : 'bc1'
|
||||
}"`;
|
||||
|
||||
useEffect(() => {
|
||||
onAddressValidityChange(!errorText);
|
||||
}, [address, errorText, onAddressValidityChange]);
|
||||
|
||||
return (
|
||||
<TextField
|
||||
value={address}
|
||||
onChange={(e) => onAddressChange(e.target.value)}
|
||||
error={!!errorText && address.length > 0}
|
||||
helperText={address.length > 0 ? errorText || helperText : helperText}
|
||||
placeholder={placeholder}
|
||||
variant="outlined"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
39
src/renderer/components/inputs/MoneroAddressTextField.tsx
Normal file
@ -0,0 +1,39 @@
|
||||
import { useEffect } from 'react';
|
||||
import { TextField } from '@material-ui/core';
|
||||
import { TextFieldProps } from '@material-ui/core/TextField/TextField';
|
||||
import { isXmrAddressValid } from 'utils/conversionUtils';
|
||||
import { isTestnet } from 'store/config';
|
||||
|
||||
export default function MoneroAddressTextField({
|
||||
address,
|
||||
onAddressChange,
|
||||
onAddressValidityChange,
|
||||
helperText,
|
||||
...props
|
||||
}: {
|
||||
address: string;
|
||||
onAddressChange: (address: string) => void;
|
||||
onAddressValidityChange: (valid: boolean) => void;
|
||||
helperText: string;
|
||||
} & TextFieldProps) {
|
||||
const placeholder = isTestnet() ? '59McWTPGc745...' : '888tNkZrPN6J...';
|
||||
const errorText = isXmrAddressValid(address, isTestnet())
|
||||
? null
|
||||
: 'Not a valid Monero address';
|
||||
|
||||
useEffect(() => {
|
||||
onAddressValidityChange(!errorText);
|
||||
}, [address, onAddressValidityChange, errorText]);
|
||||
|
||||
return (
|
||||
<TextField
|
||||
value={address}
|
||||
onChange={(e) => onAddressChange(e.target.value)}
|
||||
error={!!errorText && address.length > 0}
|
||||
helperText={address.length > 0 ? errorText || helperText : helperText}
|
||||
placeholder={placeholder}
|
||||
variant="outlined"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
22
src/renderer/components/modal/DialogHeader.tsx
Normal file
@ -0,0 +1,22 @@
|
||||
import { DialogTitle, makeStyles, Typography } from '@material-ui/core';
|
||||
|
||||
const useStyles = makeStyles({
|
||||
root: {
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
});
|
||||
|
||||
type DialogTitleProps = {
|
||||
title: string;
|
||||
};
|
||||
|
||||
export default function DialogHeader({ title }: DialogTitleProps) {
|
||||
const classes = useStyles();
|
||||
|
||||
return (
|
||||
<DialogTitle disableTypography className={classes.root}>
|
||||
<Typography variant="h6">{title}</Typography>
|
||||
</DialogTitle>
|
||||
);
|
||||
}
|
33
src/renderer/components/modal/PaperTextBox.tsx
Normal file
@ -0,0 +1,33 @@
|
||||
import { Button, makeStyles, Paper, Typography } from '@material-ui/core';
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
logsOuter: {
|
||||
overflow: 'auto',
|
||||
padding: theme.spacing(1),
|
||||
marginTop: theme.spacing(1),
|
||||
marginBottom: theme.spacing(1),
|
||||
maxHeight: '10rem',
|
||||
},
|
||||
copyButton: {
|
||||
marginTop: theme.spacing(1),
|
||||
},
|
||||
}));
|
||||
|
||||
export default function PaperTextBox({ stdOut }: { stdOut: string }) {
|
||||
const classes = useStyles();
|
||||
|
||||
function handleCopyLogs() {
|
||||
throw new Error('Not implemented');
|
||||
}
|
||||
|
||||
return (
|
||||
<Paper className={classes.logsOuter} variant="outlined">
|
||||
<Typography component="pre" variant="body2">
|
||||
{stdOut}
|
||||
</Typography>
|
||||
<Button onClick={handleCopyLogs} className={classes.copyButton}>
|
||||
Copy
|
||||
</Button>
|
||||
</Paper>
|
||||
);
|
||||
}
|
44
src/renderer/components/modal/SwapSuspendAlert.tsx
Normal file
@ -0,0 +1,44 @@
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogContentText,
|
||||
DialogTitle,
|
||||
} from '@material-ui/core';
|
||||
import IpcInvokeButton from '../IpcInvokeButton';
|
||||
|
||||
type SwapCancelAlertProps = {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export default function SwapSuspendAlert({
|
||||
open,
|
||||
onClose,
|
||||
}: SwapCancelAlertProps) {
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose}>
|
||||
<DialogTitle>Force stop running operation?</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText>
|
||||
Are you sure you want to force stop the running swap?
|
||||
</DialogContentText>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onClose} color="primary">
|
||||
No
|
||||
</Button>
|
||||
<IpcInvokeButton
|
||||
ipcChannel="suspend-current-swap"
|
||||
ipcArgs={[]}
|
||||
color="primary"
|
||||
onSuccess={onClose}
|
||||
requiresRpc
|
||||
>
|
||||
Force stop
|
||||
</IpcInvokeButton>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
170
src/renderer/components/modal/feedback/FeedbackDialog.tsx
Normal file
@ -0,0 +1,170 @@
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogContentText,
|
||||
DialogTitle,
|
||||
MenuItem,
|
||||
Select,
|
||||
TextField,
|
||||
} from '@material-ui/core';
|
||||
import { useState } from 'react';
|
||||
import { useSnackbar } from 'notistack';
|
||||
import {
|
||||
useActiveSwapInfo,
|
||||
useAppSelector,
|
||||
} from 'store/hooks';
|
||||
import { parseDateString } from 'utils/parseUtils';
|
||||
import { store } from 'renderer/store/storeRenderer';
|
||||
import { CliLog } from 'models/cliModel';
|
||||
import { submitFeedbackViaHttp } from '../../../api';
|
||||
import { PiconeroAmount } from '../../other/Units';
|
||||
import LoadingButton from '../../other/LoadingButton';
|
||||
|
||||
async function submitFeedback(body: string, swapId: string | number) {
|
||||
let attachedBody = '';
|
||||
|
||||
if (swapId !== 0 && typeof swapId === 'string') {
|
||||
const swapInfo = store.getState().rpc.state.swapInfos[swapId];
|
||||
const logs = [] as CliLog[];
|
||||
|
||||
throw new Error('Not implemented');
|
||||
|
||||
if (swapInfo === undefined) {
|
||||
throw new Error(`Swap with id ${swapId} not found`);
|
||||
}
|
||||
|
||||
attachedBody = `${JSON.stringify(swapInfo, null, 4)} \n\nLogs: ${logs
|
||||
.map((l) => JSON.stringify(l))
|
||||
.join('\n====\n')}`;
|
||||
}
|
||||
|
||||
await submitFeedbackViaHttp(body, attachedBody);
|
||||
}
|
||||
|
||||
/*
|
||||
* This component is a dialog that allows the user to submit feedback to the
|
||||
* developers. The user can enter a message and optionally attach logs from a
|
||||
* specific swap.
|
||||
* selectedSwap = 0 means no swap is attached
|
||||
*/
|
||||
function SwapSelectDropDown({
|
||||
selectedSwap,
|
||||
setSelectedSwap,
|
||||
}: {
|
||||
selectedSwap: string | number;
|
||||
setSelectedSwap: (swapId: string | number) => void;
|
||||
}) {
|
||||
const swaps = useAppSelector((state) =>
|
||||
Object.values(state.rpc.state.swapInfos),
|
||||
);
|
||||
|
||||
return (
|
||||
<Select
|
||||
value={selectedSwap}
|
||||
label="Attach logs"
|
||||
variant="outlined"
|
||||
onChange={(e) => setSelectedSwap(e.target.value as string)}
|
||||
>
|
||||
<MenuItem value={0}>Do not attach logs</MenuItem>
|
||||
{swaps.map((swap) => (
|
||||
<MenuItem value={swap.swapId}>
|
||||
Swap {swap.swapId.substring(0, 5)}... from{' '}
|
||||
{new Date(parseDateString(swap.startDate)).toDateString()} (
|
||||
<PiconeroAmount amount={swap.xmrAmount} />)
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
const MAX_FEEDBACK_LENGTH = 4000;
|
||||
|
||||
export default function FeedbackDialog({
|
||||
open,
|
||||
onClose,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [pending, setPending] = useState(false);
|
||||
const [bodyText, setBodyText] = useState('');
|
||||
const currentSwapId = useActiveSwapInfo();
|
||||
|
||||
const { enqueueSnackbar } = useSnackbar();
|
||||
|
||||
const [selectedAttachedSwap, setSelectedAttachedSwap] = useState<
|
||||
string | number
|
||||
>(currentSwapId?.swapId || 0);
|
||||
|
||||
const bodyTooLong = bodyText.length > MAX_FEEDBACK_LENGTH;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose}>
|
||||
<DialogTitle>Submit Feedback</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText>
|
||||
Got something to say? Drop us a message below. If you had an issue
|
||||
with a specific swap, select it from the dropdown to attach the logs.
|
||||
It will help us figure out what went wrong. Hit that submit button
|
||||
when you are ready. We appreciate you taking the time to share your
|
||||
thoughts!
|
||||
</DialogContentText>
|
||||
<Box style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
|
||||
<TextField
|
||||
variant="outlined"
|
||||
value={bodyText}
|
||||
onChange={(e) => setBodyText(e.target.value)}
|
||||
label={
|
||||
bodyTooLong
|
||||
? `Text is too long (${bodyText.length}/${MAX_FEEDBACK_LENGTH})`
|
||||
: 'Feedback'
|
||||
}
|
||||
multiline
|
||||
minRows={4}
|
||||
maxRows={4}
|
||||
fullWidth
|
||||
error={bodyTooLong}
|
||||
/>
|
||||
<SwapSelectDropDown
|
||||
selectedSwap={selectedAttachedSwap}
|
||||
setSelectedSwap={setSelectedAttachedSwap}
|
||||
/>
|
||||
</Box>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onClose}>Cancel</Button>
|
||||
<LoadingButton
|
||||
color="primary"
|
||||
variant="contained"
|
||||
onClick={async () => {
|
||||
if (pending) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setPending(true);
|
||||
await submitFeedback(bodyText, selectedAttachedSwap);
|
||||
enqueueSnackbar('Feedback submitted successfully!', {
|
||||
variant: 'success',
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(`Failed to submit feedback: ${e}`);
|
||||
enqueueSnackbar(`Failed to submit feedback (${e})`, {
|
||||
variant: 'error',
|
||||
});
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
onClose();
|
||||
}}
|
||||
loading={pending}
|
||||
>
|
||||
Submit
|
||||
</LoadingButton>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
136
src/renderer/components/modal/listSellers/ListSellersDialog.tsx
Normal file
@ -0,0 +1,136 @@
|
||||
import { ChangeEvent, useState } from 'react';
|
||||
import {
|
||||
DialogTitle,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogContentText,
|
||||
TextField,
|
||||
DialogActions,
|
||||
Button,
|
||||
Box,
|
||||
Chip,
|
||||
makeStyles,
|
||||
Theme,
|
||||
} from '@material-ui/core';
|
||||
import { Multiaddr } from 'multiaddr';
|
||||
import { useSnackbar } from 'notistack';
|
||||
import IpcInvokeButton from '../../IpcInvokeButton';
|
||||
|
||||
const PRESET_RENDEZVOUS_POINTS = [
|
||||
'/dns4/discover.unstoppableswap.net/tcp/8888/p2p/12D3KooWA6cnqJpVnreBVnoro8midDL9Lpzmg8oJPoAGi7YYaamE',
|
||||
'/dns4/eratosthen.es/tcp/7798/p2p/12D3KooWAh7EXXa2ZyegzLGdjvj1W4G3EXrTGrf6trraoT1MEobs',
|
||||
];
|
||||
|
||||
const useStyles = makeStyles((theme: Theme) => ({
|
||||
chipOuter: {
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: theme.spacing(1),
|
||||
},
|
||||
}));
|
||||
|
||||
type ListSellersDialogProps = {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export default function ListSellersDialog({
|
||||
open,
|
||||
onClose,
|
||||
}: ListSellersDialogProps) {
|
||||
const classes = useStyles();
|
||||
const [rendezvousAddress, setRendezvousAddress] = useState('');
|
||||
const { enqueueSnackbar } = useSnackbar();
|
||||
|
||||
function handleMultiAddrChange(event: ChangeEvent<HTMLInputElement>) {
|
||||
setRendezvousAddress(event.target.value);
|
||||
}
|
||||
|
||||
function getMultiAddressError(): string | null {
|
||||
try {
|
||||
const multiAddress = new Multiaddr(rendezvousAddress);
|
||||
if (!multiAddress.protoNames().includes('p2p')) {
|
||||
return 'The multi address must contain the peer id (/p2p/)';
|
||||
}
|
||||
return null;
|
||||
} catch (e) {
|
||||
return 'Not a valid multi address';
|
||||
}
|
||||
}
|
||||
|
||||
function handleSuccess(amountOfSellers: number) {
|
||||
let message: string;
|
||||
|
||||
switch (amountOfSellers) {
|
||||
case 0:
|
||||
message = `No providers were discovered at the rendezvous point`;
|
||||
break;
|
||||
case 1:
|
||||
message = `Discovered one provider at the rendezvous point`;
|
||||
break;
|
||||
default:
|
||||
message = `Discovered ${amountOfSellers} providers at the rendezvous point`;
|
||||
}
|
||||
|
||||
enqueueSnackbar(message, {
|
||||
variant: 'success',
|
||||
autoHideDuration: 5000,
|
||||
});
|
||||
|
||||
onClose();
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog onClose={onClose} open={open}>
|
||||
<DialogTitle>Discover swap providers</DialogTitle>
|
||||
<DialogContent dividers>
|
||||
<DialogContentText>
|
||||
The rendezvous protocol provides a way to discover providers (trading
|
||||
partners) without relying on one singular centralized institution. By
|
||||
manually connecting to a rendezvous point run by a volunteer, you can
|
||||
discover providers and then connect and swap with them.
|
||||
</DialogContentText>
|
||||
<TextField
|
||||
autoFocus
|
||||
margin="dense"
|
||||
label="Rendezvous point"
|
||||
fullWidth
|
||||
helperText={
|
||||
getMultiAddressError() || 'Multiaddress of the rendezvous point'
|
||||
}
|
||||
value={rendezvousAddress}
|
||||
onChange={handleMultiAddrChange}
|
||||
placeholder="/dns4/discover.unstoppableswap.net/tcp/8888/p2p/12D3KooWA6cnqJpVnreBVnoro8midDL9Lpzmg8oJPoAGi7YYaamE"
|
||||
error={!!getMultiAddressError()}
|
||||
/>
|
||||
<Box className={classes.chipOuter}>
|
||||
{PRESET_RENDEZVOUS_POINTS.map((rAddress) => (
|
||||
<Chip
|
||||
key={rAddress}
|
||||
clickable
|
||||
label={`${rAddress.substring(
|
||||
0,
|
||||
Math.min(rAddress.length - 1, 20),
|
||||
)}...`}
|
||||
onClick={() => setRendezvousAddress(rAddress)}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onClose}>Cancel</Button>
|
||||
<IpcInvokeButton
|
||||
variant="contained"
|
||||
disabled={!(rendezvousAddress && !getMultiAddressError())}
|
||||
color="primary"
|
||||
onSuccess={handleSuccess}
|
||||
ipcChannel="spawn-list-sellers"
|
||||
ipcArgs={[rendezvousAddress]}
|
||||
requiresRpc
|
||||
>
|
||||
Connect
|
||||
</IpcInvokeButton>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
75
src/renderer/components/modal/provider/ProviderInfo.tsx
Normal file
@ -0,0 +1,75 @@
|
||||
import { makeStyles, Box, Typography, Chip, Tooltip } from '@material-ui/core';
|
||||
import { VerifiedUser } from '@material-ui/icons';
|
||||
import { satsToBtc, secondsToDays } from 'utils/conversionUtils';
|
||||
import { ExtendedProviderStatus } from 'models/apiModel';
|
||||
import {
|
||||
MoneroBitcoinExchangeRate,
|
||||
SatsAmount,
|
||||
} from 'renderer/components/other/Units';
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
content: {
|
||||
flex: 1,
|
||||
'& *': {
|
||||
lineBreak: 'anywhere',
|
||||
},
|
||||
},
|
||||
chipsOuter: {
|
||||
display: 'flex',
|
||||
marginTop: theme.spacing(1),
|
||||
gap: theme.spacing(0.5),
|
||||
flexWrap: 'wrap',
|
||||
},
|
||||
}));
|
||||
|
||||
export default function ProviderInfo({
|
||||
provider,
|
||||
}: {
|
||||
provider: ExtendedProviderStatus;
|
||||
}) {
|
||||
const classes = useStyles();
|
||||
|
||||
return (
|
||||
<Box className={classes.content}>
|
||||
<Typography color="textSecondary" gutterBottom>
|
||||
Swap Provider
|
||||
</Typography>
|
||||
<Typography variant="h5" component="h2">
|
||||
{provider.multiAddr}
|
||||
</Typography>
|
||||
<Typography color="textSecondary" gutterBottom>
|
||||
{provider.peerId.substring(0, 8)}...{provider.peerId.slice(-8)}
|
||||
</Typography>
|
||||
<Typography variant="caption">
|
||||
Exchange rate:{' '}
|
||||
<MoneroBitcoinExchangeRate rate={satsToBtc(provider.price)} />
|
||||
<br />
|
||||
Minimum swap amount: <SatsAmount amount={provider.minSwapAmount} />
|
||||
<br />
|
||||
Maximum swap amount: <SatsAmount amount={provider.maxSwapAmount} />
|
||||
</Typography>
|
||||
<Box className={classes.chipsOuter}>
|
||||
<Chip label={provider.testnet ? 'Testnet' : 'Mainnet'} />
|
||||
{provider.uptime && (
|
||||
<Tooltip title="A high uptime indicates reliability. Providers with low uptime may be unreliable and cause swaps to take longer to complete or fail entirely.">
|
||||
<Chip label={`${Math.round(provider.uptime * 100)} % uptime`} />
|
||||
</Tooltip>
|
||||
)}
|
||||
{provider.age ? (
|
||||
<Chip
|
||||
label={`Went online ${Math.round(secondsToDays(provider.age))} ${
|
||||
provider.age === 1 ? 'day' : 'days'
|
||||
} ago`}
|
||||
/>
|
||||
) : (
|
||||
<Chip label="Discovered via rendezvous point" />
|
||||
)}
|
||||
{provider.recommended === true && (
|
||||
<Tooltip title="This provider has shown to be exceptionally reliable">
|
||||
<Chip label="Recommended" icon={<VerifiedUser />} color="primary" />
|
||||
</Tooltip>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
129
src/renderer/components/modal/provider/ProviderListDialog.tsx
Normal file
@ -0,0 +1,129 @@
|
||||
import {
|
||||
Avatar,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemAvatar,
|
||||
ListItemText,
|
||||
DialogTitle,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
Button,
|
||||
DialogContent,
|
||||
makeStyles,
|
||||
CircularProgress,
|
||||
} from '@material-ui/core';
|
||||
import AddIcon from '@material-ui/icons/Add';
|
||||
import { useState } from 'react';
|
||||
import SearchIcon from '@material-ui/icons/Search';
|
||||
import { ExtendedProviderStatus } from 'models/apiModel';
|
||||
import {
|
||||
useAllProviders,
|
||||
useAppDispatch,
|
||||
useIsRpcEndpointBusy,
|
||||
} from 'store/hooks';
|
||||
import { setSelectedProvider } from 'store/features/providersSlice';
|
||||
import { RpcMethod } from 'models/rpcModel';
|
||||
import ProviderSubmitDialog from './ProviderSubmitDialog';
|
||||
import ListSellersDialog from '../listSellers/ListSellersDialog';
|
||||
import ProviderInfo from './ProviderInfo';
|
||||
|
||||
const useStyles = makeStyles({
|
||||
dialogContent: {
|
||||
padding: 0,
|
||||
},
|
||||
});
|
||||
|
||||
type ProviderSelectDialogProps = {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export function ProviderSubmitDialogOpenButton() {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<ListItem
|
||||
autoFocus
|
||||
button
|
||||
onClick={() => {
|
||||
// Prevents background from being clicked and reopening dialog
|
||||
if (!open) {
|
||||
setOpen(true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ProviderSubmitDialog open={open} onClose={() => setOpen(false)} />
|
||||
<ListItemAvatar>
|
||||
<Avatar>
|
||||
<AddIcon />
|
||||
</Avatar>
|
||||
</ListItemAvatar>
|
||||
<ListItemText primary="Add a new provider to public registry" />
|
||||
</ListItem>
|
||||
);
|
||||
}
|
||||
|
||||
export function ListSellersDialogOpenButton() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const running = useIsRpcEndpointBusy(RpcMethod.LIST_SELLERS);
|
||||
|
||||
return (
|
||||
<ListItem
|
||||
autoFocus
|
||||
button
|
||||
disabled={running}
|
||||
onClick={() => {
|
||||
// Prevents background from being clicked and reopening dialog
|
||||
if (!open) {
|
||||
setOpen(true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ListSellersDialog open={open} onClose={() => setOpen(false)} />
|
||||
<ListItemAvatar>
|
||||
<Avatar>{running ? <CircularProgress /> : <SearchIcon />}</Avatar>
|
||||
</ListItemAvatar>
|
||||
<ListItemText primary="Discover providers by connecting to a rendezvous point" />
|
||||
</ListItem>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ProviderListDialog({
|
||||
open,
|
||||
onClose,
|
||||
}: ProviderSelectDialogProps) {
|
||||
const classes = useStyles();
|
||||
const providers = useAllProviders();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
function handleProviderChange(provider: ExtendedProviderStatus) {
|
||||
dispatch(setSelectedProvider(provider));
|
||||
onClose();
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog onClose={onClose} open={open}>
|
||||
<DialogTitle>Select a swap provider</DialogTitle>
|
||||
|
||||
<DialogContent className={classes.dialogContent} dividers>
|
||||
<List>
|
||||
{providers.map((provider) => (
|
||||
<ListItem
|
||||
button
|
||||
onClick={() => handleProviderChange(provider)}
|
||||
key={provider.peerId}
|
||||
>
|
||||
<ProviderInfo provider={provider} key={provider.peerId} />
|
||||
</ListItem>
|
||||
))}
|
||||
<ListSellersDialogOpenButton />
|
||||
<ProviderSubmitDialogOpenButton />
|
||||
</List>
|
||||
</DialogContent>
|
||||
|
||||
<DialogActions>
|
||||
<Button onClick={onClose}>Cancel</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
62
src/renderer/components/modal/provider/ProviderSelect.tsx
Normal file
@ -0,0 +1,62 @@
|
||||
import {
|
||||
makeStyles,
|
||||
Card,
|
||||
CardContent,
|
||||
Box,
|
||||
IconButton,
|
||||
} from '@material-ui/core';
|
||||
import ArrowForwardIosIcon from '@material-ui/icons/ArrowForwardIos';
|
||||
import { useState } from 'react';
|
||||
import { useAppSelector } from 'store/hooks';
|
||||
import ProviderInfo from './ProviderInfo';
|
||||
import ProviderListDialog from './ProviderListDialog';
|
||||
|
||||
const useStyles = makeStyles({
|
||||
inner: {
|
||||
textAlign: 'left',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
providerCard: {
|
||||
width: '100%',
|
||||
},
|
||||
providerCardContent: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
},
|
||||
});
|
||||
|
||||
export default function ProviderSelect() {
|
||||
const classes = useStyles();
|
||||
const [selectDialogOpen, setSelectDialogOpen] = useState(false);
|
||||
const selectedProvider = useAppSelector(
|
||||
(state) => state.providers.selectedProvider,
|
||||
);
|
||||
|
||||
if (!selectedProvider) return <>No provider selected</>;
|
||||
|
||||
function handleSelectDialogClose() {
|
||||
setSelectDialogOpen(false);
|
||||
}
|
||||
|
||||
function handleSelectDialogOpen() {
|
||||
setSelectDialogOpen(true);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<ProviderListDialog
|
||||
open={selectDialogOpen}
|
||||
onClose={handleSelectDialogClose}
|
||||
/>
|
||||
<Card variant="outlined" className={classes.providerCard}>
|
||||
<CardContent className={classes.providerCardContent}>
|
||||
<ProviderInfo provider={selectedProvider} />
|
||||
<IconButton onClick={handleSelectDialogOpen} size="small">
|
||||
<ArrowForwardIosIcon />
|
||||
</IconButton>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Box>
|
||||
);
|
||||
}
|
111
src/renderer/components/modal/provider/ProviderSubmitDialog.tsx
Normal file
@ -0,0 +1,111 @@
|
||||
import { ChangeEvent, useState } from 'react';
|
||||
import {
|
||||
DialogTitle,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogContentText,
|
||||
TextField,
|
||||
DialogActions,
|
||||
Button,
|
||||
} from '@material-ui/core';
|
||||
import { Multiaddr } from 'multiaddr';
|
||||
|
||||
type ProviderSubmitDialogProps = {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export default function ProviderSubmitDialog({
|
||||
open,
|
||||
onClose,
|
||||
}: ProviderSubmitDialogProps) {
|
||||
const [multiAddr, setMultiAddr] = useState('');
|
||||
const [peerId, setPeerId] = useState('');
|
||||
|
||||
async function handleProviderSubmit() {
|
||||
if (multiAddr && peerId) {
|
||||
await fetch('https://api.unstoppableswap.net/api/submit-provider', {
|
||||
method: 'post',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
multiAddr,
|
||||
peerId,
|
||||
}),
|
||||
});
|
||||
setMultiAddr('');
|
||||
setPeerId('');
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
|
||||
function handleMultiAddrChange(event: ChangeEvent<HTMLInputElement>) {
|
||||
setMultiAddr(event.target.value);
|
||||
}
|
||||
|
||||
function handlePeerIdChange(event: ChangeEvent<HTMLInputElement>) {
|
||||
setPeerId(event.target.value);
|
||||
}
|
||||
|
||||
function getMultiAddressError(): string | null {
|
||||
try {
|
||||
const multiAddress = new Multiaddr(multiAddr);
|
||||
if (multiAddress.protoNames().includes('p2p')) {
|
||||
return 'The multi address should not contain the peer id (/p2p/)';
|
||||
}
|
||||
if (multiAddress.protoNames().find((name) => name.includes('onion'))) {
|
||||
return 'It is currently not possible to add a provider that is only reachable via Tor';
|
||||
}
|
||||
return null;
|
||||
} catch (e) {
|
||||
return 'Not a valid multi address';
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog onClose={onClose} open={open}>
|
||||
<DialogTitle>Submit a provider to the public registry</DialogTitle>
|
||||
<DialogContent dividers>
|
||||
<DialogContentText>
|
||||
If the provider is valid and reachable, it will be displayed to all
|
||||
other users to trade with.
|
||||
</DialogContentText>
|
||||
<TextField
|
||||
autoFocus
|
||||
margin="dense"
|
||||
label="Multiaddress"
|
||||
fullWidth
|
||||
helperText={
|
||||
getMultiAddressError() ||
|
||||
'Tells the swap client where the provider can be reached'
|
||||
}
|
||||
value={multiAddr}
|
||||
onChange={handleMultiAddrChange}
|
||||
placeholder="/ip4/182.3.21.93/tcp/9939"
|
||||
error={!!getMultiAddressError()}
|
||||
/>
|
||||
<TextField
|
||||
margin="dense"
|
||||
label="Peer ID"
|
||||
fullWidth
|
||||
helperText="Identifies the provider and allows for secure communication"
|
||||
value={peerId}
|
||||
onChange={handlePeerIdChange}
|
||||
placeholder="12D3KooWCdMKjesXMJz1SiZ7HgotrxuqhQJbP5sgBm2BwP1cqThi"
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onClose}>Cancel</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={handleProviderSubmit}
|
||||
disabled={!(multiAddr && peerId && !getMultiAddressError())}
|
||||
color="primary"
|
||||
>
|
||||
Submit
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
22
src/renderer/components/modal/swap/BitcoinQrCode.tsx
Normal file
@ -0,0 +1,22 @@
|
||||
import QRCode from 'react-qr-code';
|
||||
import { Box } from '@material-ui/core';
|
||||
|
||||
export default function BitcoinQrCode({ address }: { address: string }) {
|
||||
return (
|
||||
<Box
|
||||
style={{
|
||||
height: '100%',
|
||||
margin: '0 auto',
|
||||
}}
|
||||
>
|
||||
<QRCode
|
||||
value={`bitcoin:${address}`}
|
||||
size={256}
|
||||
style={{ height: 'auto', maxWidth: '100%', width: '100%' }}
|
||||
/* eslint-disable-next-line @typescript-eslint/ban-ts-comment */
|
||||
/* @ts-ignore */
|
||||
viewBox="0 0 256 256"
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
@ -0,0 +1,25 @@
|
||||
import { isTestnet } from 'store/config';
|
||||
import { getBitcoinTxExplorerUrl } from 'utils/conversionUtils';
|
||||
import BitcoinIcon from 'renderer/components/icons/BitcoinIcon';
|
||||
import { ReactNode } from 'react';
|
||||
import TransactionInfoBox from './TransactionInfoBox';
|
||||
|
||||
type Props = {
|
||||
title: string;
|
||||
txId: string;
|
||||
additionalContent: ReactNode;
|
||||
loading: boolean;
|
||||
};
|
||||
|
||||
export default function BitcoinTransactionInfoBox({ txId, ...props }: Props) {
|
||||
const explorerUrl = getBitcoinTxExplorerUrl(txId, isTestnet());
|
||||
|
||||
return (
|
||||
<TransactionInfoBox
|
||||
txId={txId}
|
||||
explorerUrl={explorerUrl}
|
||||
icon={<BitcoinIcon />}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
@ -0,0 +1,35 @@
|
||||
import {
|
||||
Box,
|
||||
CircularProgress,
|
||||
makeStyles,
|
||||
Typography,
|
||||
} from '@material-ui/core';
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
subtitle: {
|
||||
paddingTop: theme.spacing(1),
|
||||
},
|
||||
}));
|
||||
|
||||
export default function CircularProgressWithSubtitle({
|
||||
description,
|
||||
}: {
|
||||
description: string | ReactNode;
|
||||
}) {
|
||||
const classes = useStyles();
|
||||
|
||||
return (
|
||||
<Box
|
||||
display="flex"
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
flexDirection="column"
|
||||
>
|
||||
<CircularProgress size={50} />
|
||||
<Typography variant="subtitle2" className={classes.subtitle}>
|
||||
{description}
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
17
src/renderer/components/modal/swap/ClipbiardIconButton.tsx
Normal file
@ -0,0 +1,17 @@
|
||||
import { Button } from '@material-ui/core';
|
||||
import { ButtonProps } from '@material-ui/core/Button/Button';
|
||||
|
||||
export default function ClipboardIconButton({
|
||||
text,
|
||||
...props
|
||||
}: { text: string } & ButtonProps) {
|
||||
function writeToClipboard() {
|
||||
throw new Error('Not implemented');
|
||||
}
|
||||
|
||||
return (
|
||||
<Button onClick={writeToClipboard} {...props}>
|
||||
Copy
|
||||
</Button>
|
||||
);
|
||||
}
|
53
src/renderer/components/modal/swap/DepositAddressInfoBox.tsx
Normal file
@ -0,0 +1,53 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { Box, Typography } from '@material-ui/core';
|
||||
import FileCopyOutlinedIcon from '@material-ui/icons/FileCopyOutlined';
|
||||
import InfoBox from './InfoBox';
|
||||
import ClipboardIconButton from './ClipbiardIconButton';
|
||||
import BitcoinQrCode from './BitcoinQrCode';
|
||||
|
||||
type Props = {
|
||||
title: string;
|
||||
address: string;
|
||||
additionalContent: ReactNode;
|
||||
icon: ReactNode;
|
||||
};
|
||||
|
||||
export default function DepositAddressInfoBox({
|
||||
title,
|
||||
address,
|
||||
additionalContent,
|
||||
icon,
|
||||
}: Props) {
|
||||
return (
|
||||
<InfoBox
|
||||
title={title}
|
||||
mainContent={<Typography variant="h5">{address}</Typography>}
|
||||
additionalContent={
|
||||
<Box>
|
||||
<Box>
|
||||
<ClipboardIconButton
|
||||
text={address}
|
||||
endIcon={<FileCopyOutlinedIcon />}
|
||||
color="primary"
|
||||
variant="contained"
|
||||
size="medium"
|
||||
/>
|
||||
<Box
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
gap: '0.5rem',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Box>{additionalContent}</Box>
|
||||
<BitcoinQrCode address={address} />
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
}
|
||||
icon={icon}
|
||||
loading={false}
|
||||
/>
|
||||
);
|
||||
}
|
53
src/renderer/components/modal/swap/InfoBox.tsx
Normal file
@ -0,0 +1,53 @@
|
||||
import {
|
||||
Box,
|
||||
LinearProgress,
|
||||
makeStyles,
|
||||
Paper,
|
||||
Typography,
|
||||
} from '@material-ui/core';
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
type Props = {
|
||||
title: ReactNode;
|
||||
mainContent: ReactNode;
|
||||
additionalContent: ReactNode;
|
||||
loading: boolean;
|
||||
icon: ReactNode;
|
||||
};
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
outer: {
|
||||
padding: theme.spacing(1.5),
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: theme.spacing(1),
|
||||
},
|
||||
upperContent: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: theme.spacing(0.5),
|
||||
},
|
||||
}));
|
||||
|
||||
export default function InfoBox({
|
||||
title,
|
||||
mainContent,
|
||||
additionalContent,
|
||||
icon,
|
||||
loading,
|
||||
}: Props) {
|
||||
const classes = useStyles();
|
||||
|
||||
return (
|
||||
<Paper variant="outlined" className={classes.outer}>
|
||||
<Typography variant="subtitle1">{title}</Typography>
|
||||
<Box className={classes.upperContent}>
|
||||
{icon}
|
||||
{mainContent}
|
||||
</Box>
|
||||
{loading ? <LinearProgress variant="indeterminate" /> : null}
|
||||
<Box>{additionalContent}</Box>
|
||||
</Paper>
|
||||
);
|
||||
}
|
@ -0,0 +1,25 @@
|
||||
import { isTestnet } from 'store/config';
|
||||
import { getMoneroTxExplorerUrl } from 'utils/conversionUtils';
|
||||
import MoneroIcon from 'renderer/components/icons/MoneroIcon';
|
||||
import { ReactNode } from 'react';
|
||||
import TransactionInfoBox from './TransactionInfoBox';
|
||||
|
||||
type Props = {
|
||||
title: string;
|
||||
txId: string;
|
||||
additionalContent: ReactNode;
|
||||
loading: boolean;
|
||||
};
|
||||
|
||||
export default function MoneroTransactionInfoBox({ txId, ...props }: Props) {
|
||||
const explorerUrl = getMoneroTxExplorerUrl(txId, isTestnet());
|
||||
|
||||
return (
|
||||
<TransactionInfoBox
|
||||
txId={txId}
|
||||
explorerUrl={explorerUrl}
|
||||
icon={<MoneroIcon />}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
90
src/renderer/components/modal/swap/SwapDialog.tsx
Normal file
@ -0,0 +1,90 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
makeStyles,
|
||||
} from '@material-ui/core';
|
||||
import { useAppDispatch, useAppSelector } from 'store/hooks';
|
||||
import { swapReset } from 'store/features/swapSlice';
|
||||
import SwapStatePage from './pages/SwapStatePage';
|
||||
import SwapStateStepper from './SwapStateStepper';
|
||||
import SwapSuspendAlert from '../SwapSuspendAlert';
|
||||
import SwapDialogTitle from './SwapDialogTitle';
|
||||
import DebugPage from './pages/DebugPage';
|
||||
|
||||
const useStyles = makeStyles({
|
||||
content: {
|
||||
minHeight: '25rem',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
});
|
||||
|
||||
export default function SwapDialog({
|
||||
open,
|
||||
onClose,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const classes = useStyles();
|
||||
const swap = useAppSelector((state) => state.swap);
|
||||
const [debug, setDebug] = useState(false);
|
||||
const [openSuspendAlert, setOpenSuspendAlert] = useState(false);
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
function onCancel() {
|
||||
if (swap.processRunning) {
|
||||
setOpenSuspendAlert(true);
|
||||
} else {
|
||||
onClose();
|
||||
setTimeout(() => dispatch(swapReset()), 0);
|
||||
}
|
||||
}
|
||||
|
||||
// This prevents an issue where the Dialog is shown for a split second without a present swap state
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onCancel} maxWidth="md" fullWidth>
|
||||
<SwapDialogTitle
|
||||
debug={debug}
|
||||
setDebug={setDebug}
|
||||
title="Swap Bitcoin for Monero"
|
||||
/>
|
||||
|
||||
<DialogContent dividers className={classes.content}>
|
||||
{debug ? (
|
||||
<DebugPage />
|
||||
) : (
|
||||
<>
|
||||
<SwapStatePage swapState={swap.state} />
|
||||
<SwapStateStepper />
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
|
||||
<DialogActions>
|
||||
<Button onClick={onCancel} variant="text">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color="primary"
|
||||
variant="contained"
|
||||
onClick={onCancel}
|
||||
disabled={!(swap.state !== null && !swap.processRunning)}
|
||||
>
|
||||
Done
|
||||
</Button>
|
||||
</DialogActions>
|
||||
|
||||
<SwapSuspendAlert
|
||||
open={openSuspendAlert}
|
||||
onClose={() => setOpenSuspendAlert(false)}
|
||||
/>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
45
src/renderer/components/modal/swap/SwapDialogTitle.tsx
Normal file
@ -0,0 +1,45 @@
|
||||
import {
|
||||
Box,
|
||||
DialogTitle,
|
||||
makeStyles,
|
||||
Typography,
|
||||
} from '@material-ui/core';
|
||||
import TorStatusBadge from './pages/TorStatusBadge';
|
||||
import FeedbackSubmitBadge from './pages/FeedbackSubmitBadge';
|
||||
import DebugPageSwitchBadge from './pages/DebugPageSwitchBadge';
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
root: {
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
},
|
||||
rightSide: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gridGap: theme.spacing(1),
|
||||
},
|
||||
}));
|
||||
|
||||
export default function SwapDialogTitle({
|
||||
title,
|
||||
debug,
|
||||
setDebug,
|
||||
}: {
|
||||
title: string;
|
||||
debug: boolean;
|
||||
setDebug: (d: boolean) => void;
|
||||
}) {
|
||||
const classes = useStyles();
|
||||
|
||||
return (
|
||||
<DialogTitle disableTypography className={classes.root}>
|
||||
<Typography variant="h6">{title}</Typography>
|
||||
<Box className={classes.rightSide}>
|
||||
<FeedbackSubmitBadge />
|
||||
<DebugPageSwitchBadge enabled={debug} setEnabled={setDebug} />
|
||||
<TorStatusBadge />
|
||||
</Box>
|
||||
</DialogTitle>
|
||||
);
|
||||
}
|
166
src/renderer/components/modal/swap/SwapStateStepper.tsx
Normal file
@ -0,0 +1,166 @@
|
||||
import { Step, StepLabel, Stepper, Typography } from '@material-ui/core';
|
||||
import { SwapSpawnType } from 'models/cliModel';
|
||||
import { SwapStateName } from 'models/rpcModel';
|
||||
import { useActiveSwapInfo, useAppSelector } from 'store/hooks';
|
||||
import { exhaustiveGuard } from 'utils/typescriptUtils';
|
||||
|
||||
export enum PathType {
|
||||
HAPPY_PATH = 'happy path',
|
||||
UNHAPPY_PATH = 'unhappy path',
|
||||
}
|
||||
|
||||
function getActiveStep(
|
||||
stateName: SwapStateName | null,
|
||||
processExited: boolean,
|
||||
): [PathType, number, boolean] {
|
||||
switch (stateName) {
|
||||
/// // Happy Path
|
||||
// Step: 0 (Waiting for Bitcoin lock tx to be published)
|
||||
case null:
|
||||
return [PathType.HAPPY_PATH, 0, false];
|
||||
case SwapStateName.Started:
|
||||
case SwapStateName.SwapSetupCompleted:
|
||||
return [PathType.HAPPY_PATH, 0, processExited];
|
||||
|
||||
// Step: 1 (Waiting for Bitcoin Lock confirmation and XMR Lock Publication)
|
||||
// We have locked the Bitcoin and are waiting for the other party to lock their XMR
|
||||
case SwapStateName.BtcLocked:
|
||||
return [PathType.HAPPY_PATH, 1, processExited];
|
||||
|
||||
// Step: 2 (Waiting for XMR Lock confirmation)
|
||||
// We have locked the Bitcoin and the other party has locked their XMR
|
||||
case SwapStateName.XmrLockProofReceived:
|
||||
return [PathType.HAPPY_PATH, 1, processExited];
|
||||
|
||||
// Step: 3 (Sending Encrypted Signature and waiting for Bitcoin Redemption)
|
||||
// The XMR lock transaction has been confirmed
|
||||
// We now need to send the encrypted signature to the other party and wait for them to redeem the Bitcoin
|
||||
case SwapStateName.XmrLocked:
|
||||
case SwapStateName.EncSigSent:
|
||||
return [PathType.HAPPY_PATH, 2, processExited];
|
||||
|
||||
// Step: 4 (Waiting for XMR Redemption)
|
||||
case SwapStateName.BtcRedeemed:
|
||||
return [PathType.HAPPY_PATH, 3, processExited];
|
||||
|
||||
// Step: 4 (Completed) (Swap completed, XMR redeemed)
|
||||
case SwapStateName.XmrRedeemed:
|
||||
return [PathType.HAPPY_PATH, 4, false];
|
||||
|
||||
// Edge Case of Happy Path where the swap is safely aborted. We "fail" at the first step.
|
||||
case SwapStateName.SafelyAborted:
|
||||
return [PathType.HAPPY_PATH, 0, true];
|
||||
|
||||
// // Unhappy Path
|
||||
// Step: 1 (Cancelling swap, checking if cancel transaction has been published already by the other party)
|
||||
case SwapStateName.CancelTimelockExpired:
|
||||
return [PathType.UNHAPPY_PATH, 0, processExited];
|
||||
|
||||
// Step: 2 (Attempt to publish the Bitcoin refund transaction)
|
||||
case SwapStateName.BtcCancelled:
|
||||
return [PathType.UNHAPPY_PATH, 1, processExited];
|
||||
|
||||
// Step: 2 (Completed) (Bitcoin refunded)
|
||||
case SwapStateName.BtcRefunded:
|
||||
return [PathType.UNHAPPY_PATH, 2, false];
|
||||
|
||||
// Step: 2 (We failed to publish the Bitcoin refund transaction)
|
||||
// We failed to publish the Bitcoin refund transaction because the timelock has expired.
|
||||
// We will be punished. Nothing we can do about it now.
|
||||
case SwapStateName.BtcPunished:
|
||||
return [PathType.UNHAPPY_PATH, 1, true];
|
||||
default:
|
||||
return exhaustiveGuard(stateName);
|
||||
}
|
||||
}
|
||||
|
||||
function HappyPathStepper({
|
||||
activeStep,
|
||||
error,
|
||||
}: {
|
||||
activeStep: number;
|
||||
error: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Stepper activeStep={activeStep}>
|
||||
<Step key={0}>
|
||||
<StepLabel
|
||||
optional={<Typography variant="caption">~12min</Typography>}
|
||||
error={error && activeStep === 0}
|
||||
>
|
||||
Locking your BTC
|
||||
</StepLabel>
|
||||
</Step>
|
||||
<Step key={1}>
|
||||
<StepLabel
|
||||
optional={<Typography variant="caption">~18min</Typography>}
|
||||
error={error && activeStep === 1}
|
||||
>
|
||||
They lock their XMR
|
||||
</StepLabel>
|
||||
</Step>
|
||||
<Step key={2}>
|
||||
<StepLabel
|
||||
optional={<Typography variant="caption">~2min</Typography>}
|
||||
error={error && activeStep === 2}
|
||||
>
|
||||
They redeem the BTC
|
||||
</StepLabel>
|
||||
</Step>
|
||||
<Step key={3}>
|
||||
<StepLabel
|
||||
optional={<Typography variant="caption">~2min</Typography>}
|
||||
error={error && activeStep === 3}
|
||||
>
|
||||
Redeeming your XMR
|
||||
</StepLabel>
|
||||
</Step>
|
||||
</Stepper>
|
||||
);
|
||||
}
|
||||
|
||||
function UnhappyPathStepper({
|
||||
activeStep,
|
||||
error,
|
||||
}: {
|
||||
activeStep: number;
|
||||
error: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Stepper activeStep={activeStep}>
|
||||
<Step key={0}>
|
||||
<StepLabel
|
||||
optional={<Typography variant="caption">~20min</Typography>}
|
||||
error={error && activeStep === 0}
|
||||
>
|
||||
Cancelling swap
|
||||
</StepLabel>
|
||||
</Step>
|
||||
<Step key={1}>
|
||||
<StepLabel
|
||||
optional={<Typography variant="caption">~20min</Typography>}
|
||||
error={error && activeStep === 1}
|
||||
>
|
||||
Refunding your BTC
|
||||
</StepLabel>
|
||||
</Step>
|
||||
</Stepper>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SwapStateStepper() {
|
||||
const currentSwapSpawnType = useAppSelector((s) => s.swap.spawnType);
|
||||
const stateName = useActiveSwapInfo()?.stateName ?? null;
|
||||
const processExited = useAppSelector((s) => !s.swap.processRunning);
|
||||
const [pathType, activeStep, error] = getActiveStep(stateName, processExited);
|
||||
|
||||
// If the current swap is being manually cancelled and refund, we want to show the unhappy path even though the current state is not a "unhappy" state
|
||||
if (currentSwapSpawnType === SwapSpawnType.CANCEL_REFUND) {
|
||||
return <UnhappyPathStepper activeStep={0} error={error} />;
|
||||
}
|
||||
|
||||
if (pathType === PathType.HAPPY_PATH) {
|
||||
return <HappyPathStepper activeStep={activeStep} error={error} />;
|
||||
}
|
||||
return <UnhappyPathStepper activeStep={activeStep} error={error} />;
|
||||
}
|
40
src/renderer/components/modal/swap/TransactionInfoBox.tsx
Normal file
@ -0,0 +1,40 @@
|
||||
import { Link, Typography } from '@material-ui/core';
|
||||
import { ReactNode } from 'react';
|
||||
import InfoBox from './InfoBox';
|
||||
|
||||
type TransactionInfoBoxProps = {
|
||||
title: string;
|
||||
txId: string;
|
||||
explorerUrl: string;
|
||||
additionalContent: ReactNode;
|
||||
loading: boolean;
|
||||
icon: JSX.Element;
|
||||
};
|
||||
|
||||
export default function TransactionInfoBox({
|
||||
title,
|
||||
txId,
|
||||
explorerUrl,
|
||||
additionalContent,
|
||||
icon,
|
||||
loading,
|
||||
}: TransactionInfoBoxProps) {
|
||||
return (
|
||||
<InfoBox
|
||||
title={title}
|
||||
mainContent={<Typography variant="h5">{txId}</Typography>}
|
||||
loading={loading}
|
||||
additionalContent={
|
||||
<>
|
||||
<Typography variant="subtitle2">{additionalContent}</Typography>
|
||||
<Typography variant="body1">
|
||||
<Link href={explorerUrl} target="_blank">
|
||||
View on explorer
|
||||
</Link>
|
||||
</Typography>
|
||||
</>
|
||||
}
|
||||
icon={icon}
|
||||
/>
|
||||
);
|
||||
}
|
36
src/renderer/components/modal/swap/pages/DebugPage.tsx
Normal file
@ -0,0 +1,36 @@
|
||||
import { Box, DialogContentText } from '@material-ui/core';
|
||||
import { useActiveSwapInfo, useAppSelector } from 'store/hooks';
|
||||
import CliLogsBox from '../../../other/RenderedCliLog';
|
||||
import JsonTreeView from '../../../other/JSONViewTree';
|
||||
|
||||
export default function DebugPage() {
|
||||
const torStdOut = useAppSelector((s) => s.tor.stdOut);
|
||||
const logs = useAppSelector((s) => s.swap.logs);
|
||||
const guiState = useAppSelector((s) => s.swap);
|
||||
const cliState = useActiveSwapInfo();
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<DialogContentText>
|
||||
<Box
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '8px',
|
||||
}}
|
||||
>
|
||||
<CliLogsBox logs={logs} label="Logs relevant to the swap" />
|
||||
<JsonTreeView
|
||||
data={guiState}
|
||||
label="Internal GUI State (inferred from Logs)"
|
||||
/>
|
||||
<JsonTreeView
|
||||
data={cliState}
|
||||
label="Swap Daemon State (exposed via API)"
|
||||
/>
|
||||
<CliLogsBox label="Tor Daemon Logs" logs={torStdOut.split('\n')} />
|
||||
</Box>
|
||||
</DialogContentText>
|
||||
</Box>
|
||||
);
|
||||
}
|
@ -0,0 +1,26 @@
|
||||
import { Tooltip } from '@material-ui/core';
|
||||
import IconButton from '@material-ui/core/IconButton';
|
||||
import DeveloperBoardIcon from '@material-ui/icons/DeveloperBoard';
|
||||
|
||||
export default function DebugPageSwitchBadge({
|
||||
enabled,
|
||||
setEnabled,
|
||||
}: {
|
||||
enabled: boolean;
|
||||
setEnabled: (enabled: boolean) => void;
|
||||
}) {
|
||||
const handleToggle = () => {
|
||||
setEnabled(!enabled);
|
||||
};
|
||||
|
||||
return (
|
||||
<Tooltip title={enabled ? 'Hide debug view' : 'Show debug view'}>
|
||||
<IconButton
|
||||
onClick={handleToggle}
|
||||
color={enabled ? 'primary' : 'default'}
|
||||
>
|
||||
<DeveloperBoardIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
@ -0,0 +1,22 @@
|
||||
import { IconButton } from '@material-ui/core';
|
||||
import FeedbackIcon from '@material-ui/icons/Feedback';
|
||||
import FeedbackDialog from '../../feedback/FeedbackDialog';
|
||||
import { useState } from 'react';
|
||||
|
||||
export default function FeedbackSubmitBadge() {
|
||||
const [showFeedbackDialog, setShowFeedbackDialog] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
{showFeedbackDialog && (
|
||||
<FeedbackDialog
|
||||
open={showFeedbackDialog}
|
||||
onClose={() => setShowFeedbackDialog(false)}
|
||||
/>
|
||||
)}
|
||||
<IconButton onClick={() => setShowFeedbackDialog(true)}>
|
||||
<FeedbackIcon />
|
||||
</IconButton>
|
||||
</>
|
||||
);
|
||||
}
|
106
src/renderer/components/modal/swap/pages/SwapStatePage.tsx
Normal file
@ -0,0 +1,106 @@
|
||||
import { Box } from '@material-ui/core';
|
||||
import { useAppSelector } from 'store/hooks';
|
||||
import {
|
||||
isSwapStateBtcCancelled,
|
||||
isSwapStateBtcLockInMempool,
|
||||
isSwapStateBtcPunished,
|
||||
isSwapStateBtcRedemeed,
|
||||
isSwapStateBtcRefunded,
|
||||
isSwapStateInitiated,
|
||||
isSwapStateProcessExited,
|
||||
isSwapStateReceivedQuote,
|
||||
isSwapStateStarted,
|
||||
isSwapStateWaitingForBtcDeposit,
|
||||
isSwapStateXmrLocked,
|
||||
isSwapStateXmrLockInMempool,
|
||||
isSwapStateXmrRedeemInMempool,
|
||||
SwapState,
|
||||
} from '../../../../../models/storeModel';
|
||||
import InitiatedPage from './init/InitiatedPage';
|
||||
import WaitingForBitcoinDepositPage from './init/WaitingForBitcoinDepositPage';
|
||||
import StartedPage from './in_progress/StartedPage';
|
||||
import BitcoinLockTxInMempoolPage from './in_progress/BitcoinLockTxInMempoolPage';
|
||||
import XmrLockTxInMempoolPage from './in_progress/XmrLockInMempoolPage';
|
||||
// eslint-disable-next-line import/no-cycle
|
||||
import ProcessExitedPage from './exited/ProcessExitedPage';
|
||||
import XmrRedeemInMempoolPage from './done/XmrRedeemInMempoolPage';
|
||||
import ReceivedQuotePage from './in_progress/ReceivedQuotePage';
|
||||
import BitcoinRedeemedPage from './in_progress/BitcoinRedeemedPage';
|
||||
import InitPage from './init/InitPage';
|
||||
import XmrLockedPage from './in_progress/XmrLockedPage';
|
||||
import BitcoinCancelledPage from './in_progress/BitcoinCancelledPage';
|
||||
import BitcoinRefundedPage from './done/BitcoinRefundedPage';
|
||||
import BitcoinPunishedPage from './done/BitcoinPunishedPage';
|
||||
import { SyncingMoneroWalletPage } from './in_progress/SyncingMoneroWalletPage';
|
||||
|
||||
export default function SwapStatePage({
|
||||
swapState,
|
||||
}: {
|
||||
swapState: SwapState | null;
|
||||
}) {
|
||||
const isSyncingMoneroWallet = useAppSelector(
|
||||
(state) => state.rpc.state.moneroWallet.isSyncing,
|
||||
);
|
||||
|
||||
if (isSyncingMoneroWallet) {
|
||||
return <SyncingMoneroWalletPage />;
|
||||
}
|
||||
|
||||
if (swapState === null) {
|
||||
return <InitPage />;
|
||||
}
|
||||
if (isSwapStateInitiated(swapState)) {
|
||||
return <InitiatedPage />;
|
||||
}
|
||||
if (isSwapStateReceivedQuote(swapState)) {
|
||||
return <ReceivedQuotePage />;
|
||||
}
|
||||
if (isSwapStateWaitingForBtcDeposit(swapState)) {
|
||||
return <WaitingForBitcoinDepositPage state={swapState} />;
|
||||
}
|
||||
if (isSwapStateStarted(swapState)) {
|
||||
return <StartedPage state={swapState} />;
|
||||
}
|
||||
if (isSwapStateBtcLockInMempool(swapState)) {
|
||||
return <BitcoinLockTxInMempoolPage state={swapState} />;
|
||||
}
|
||||
if (isSwapStateXmrLockInMempool(swapState)) {
|
||||
return <XmrLockTxInMempoolPage state={swapState} />;
|
||||
}
|
||||
if (isSwapStateXmrLocked(swapState)) {
|
||||
return <XmrLockedPage />;
|
||||
}
|
||||
if (isSwapStateBtcRedemeed(swapState)) {
|
||||
return <BitcoinRedeemedPage />;
|
||||
}
|
||||
if (isSwapStateXmrRedeemInMempool(swapState)) {
|
||||
return <XmrRedeemInMempoolPage state={swapState} />;
|
||||
}
|
||||
if (isSwapStateBtcCancelled(swapState)) {
|
||||
return <BitcoinCancelledPage />;
|
||||
}
|
||||
if (isSwapStateBtcRefunded(swapState)) {
|
||||
return <BitcoinRefundedPage state={swapState} />;
|
||||
}
|
||||
if (isSwapStateBtcPunished(swapState)) {
|
||||
return <BitcoinPunishedPage />;
|
||||
}
|
||||
if (isSwapStateProcessExited(swapState)) {
|
||||
return <ProcessExitedPage state={swapState} />;
|
||||
}
|
||||
|
||||
console.error(
|
||||
`No swap state page found for swap state State: ${JSON.stringify(
|
||||
swapState,
|
||||
null,
|
||||
4,
|
||||
)}`,
|
||||
);
|
||||
return (
|
||||
<Box>
|
||||
No information to display
|
||||
<br />
|
||||
State: ${JSON.stringify(swapState, null, 4)}
|
||||
</Box>
|
||||
);
|
||||
}
|
19
src/renderer/components/modal/swap/pages/TorStatusBadge.tsx
Normal file
@ -0,0 +1,19 @@
|
||||
import { IconButton, Tooltip } from '@material-ui/core';
|
||||
import { useAppSelector } from 'store/hooks';
|
||||
import TorIcon from '../../../icons/TorIcon';
|
||||
|
||||
export default function TorStatusBadge() {
|
||||
const tor = useAppSelector((s) => s.tor);
|
||||
|
||||
if (tor.processRunning) {
|
||||
return (
|
||||
<Tooltip title="Tor is running in the background">
|
||||
<IconButton>
|
||||
<TorIcon htmlColor="#7D4698" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return <></>;
|
||||
}
|
@ -0,0 +1,15 @@
|
||||
import { Box, DialogContentText } from '@material-ui/core';
|
||||
import FeedbackInfoBox from '../../../../pages/help/FeedbackInfoBox';
|
||||
|
||||
export default function BitcoinPunishedPage() {
|
||||
return (
|
||||
<Box>
|
||||
<DialogContentText>
|
||||
Unfortunately, the swap was not successful, and you've incurred a
|
||||
penalty because the swap was not refunded in time. Both the Bitcoin and
|
||||
Monero are irretrievable.
|
||||
</DialogContentText>
|
||||
<FeedbackInfoBox />
|
||||
</Box>
|
||||
);
|
||||
}
|
@ -0,0 +1,43 @@
|
||||
import { Box, DialogContentText } from '@material-ui/core';
|
||||
import { SwapStateBtcRefunded } from 'models/storeModel';
|
||||
import { useActiveSwapInfo } from 'store/hooks';
|
||||
import BitcoinTransactionInfoBox from '../../BitcoinTransactionInfoBox';
|
||||
import FeedbackInfoBox from '../../../../pages/help/FeedbackInfoBox';
|
||||
|
||||
export default function BitcoinRefundedPage({
|
||||
state,
|
||||
}: {
|
||||
state: SwapStateBtcRefunded | null;
|
||||
}) {
|
||||
const swap = useActiveSwapInfo();
|
||||
const additionalContent = swap
|
||||
? `Refund address: ${swap.btcRefundAddress}`
|
||||
: null;
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<DialogContentText>
|
||||
Unfortunately, the swap was not successful. However, rest assured that
|
||||
all your Bitcoin has been refunded to the specified address. The swap
|
||||
process is now complete, and you are free to exit the application.
|
||||
</DialogContentText>
|
||||
<Box
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '0.5rem',
|
||||
}}
|
||||
>
|
||||
{state && (
|
||||
<BitcoinTransactionInfoBox
|
||||
title="Bitcoin Refund Transaction"
|
||||
txId={state.bobBtcRefundTxId}
|
||||
loading={false}
|
||||
additionalContent={additionalContent}
|
||||
/>
|
||||
)}
|
||||
<FeedbackInfoBox />
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
@ -0,0 +1,49 @@
|
||||
import { Box, DialogContentText } from '@material-ui/core';
|
||||
import { SwapStateXmrRedeemInMempool } from 'models/storeModel';
|
||||
import { useActiveSwapInfo } from 'store/hooks';
|
||||
import { getSwapXmrAmount } from 'models/rpcModel';
|
||||
import MoneroTransactionInfoBox from '../../MoneroTransactionInfoBox';
|
||||
import FeedbackInfoBox from '../../../../pages/help/FeedbackInfoBox';
|
||||
|
||||
type XmrRedeemInMempoolPageProps = {
|
||||
state: SwapStateXmrRedeemInMempool | null;
|
||||
};
|
||||
|
||||
export default function XmrRedeemInMempoolPage({
|
||||
state,
|
||||
}: XmrRedeemInMempoolPageProps) {
|
||||
const swap = useActiveSwapInfo();
|
||||
const additionalContent = swap
|
||||
? `This transaction transfers ${getSwapXmrAmount(swap).toFixed(6)} XMR to ${
|
||||
state?.bobXmrRedeemAddress
|
||||
}`
|
||||
: null;
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<DialogContentText>
|
||||
The swap was successful and the Monero has been sent to the address you
|
||||
specified. The swap is completed and you may exit the application now.
|
||||
</DialogContentText>
|
||||
<Box
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '0.5rem',
|
||||
}}
|
||||
>
|
||||
{state && (
|
||||
<>
|
||||
<MoneroTransactionInfoBox
|
||||
title="Monero Redeem Transaction"
|
||||
txId={state.bobXmrRedeemTxId}
|
||||
additionalContent={additionalContent}
|
||||
loading={false}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<FeedbackInfoBox />
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
@ -0,0 +1,71 @@
|
||||
import { Box, DialogContentText } from '@material-ui/core';
|
||||
import { useActiveSwapInfo, useAppSelector } from 'store/hooks';
|
||||
import { SwapStateProcessExited } from 'models/storeModel';
|
||||
import CliLogsBox from '../../../../other/RenderedCliLog';
|
||||
import { SwapSpawnType } from 'models/cliModel';
|
||||
|
||||
export default function ProcessExitedAndNotDonePage({
|
||||
state,
|
||||
}: {
|
||||
state: SwapStateProcessExited;
|
||||
}) {
|
||||
const swap = useActiveSwapInfo();
|
||||
const logs = useAppSelector((s) => s.swap.logs);
|
||||
const spawnType = useAppSelector((s) => s.swap.spawnType);
|
||||
|
||||
function getText() {
|
||||
const isCancelRefund = spawnType === SwapSpawnType.CANCEL_REFUND;
|
||||
const hasRpcError = state.rpcError != null;
|
||||
const hasSwap = swap != null;
|
||||
|
||||
let messages = [];
|
||||
|
||||
messages.push(
|
||||
isCancelRefund
|
||||
? 'The manual cancel and refund was unsuccessful.'
|
||||
: 'The swap exited unexpectedly without completing.',
|
||||
);
|
||||
|
||||
if (!hasSwap && !isCancelRefund) {
|
||||
messages.push('No funds were locked.');
|
||||
}
|
||||
|
||||
messages.push(
|
||||
hasRpcError
|
||||
? 'Check the error and the logs below for more information.'
|
||||
: 'Check the logs below for more information.',
|
||||
);
|
||||
|
||||
if (hasSwap) {
|
||||
messages.push(`The swap is in the "${swap.stateName}" state.`);
|
||||
if (!isCancelRefund) {
|
||||
messages.push(
|
||||
'Try resuming the swap or attempt to initiate a manual cancel and refund.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return messages.join(' ');
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<DialogContentText>{getText()}</DialogContentText>
|
||||
<Box
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '0.5rem',
|
||||
}}
|
||||
>
|
||||
{state.rpcError && (
|
||||
<CliLogsBox
|
||||
logs={[state.rpcError]}
|
||||
label="Error returned by the Swap Daemon"
|
||||
/>
|
||||
)}
|
||||
<CliLogsBox logs={logs} label="Logs relevant to the swap" />
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
@ -0,0 +1,47 @@
|
||||
import { useActiveSwapInfo } from 'store/hooks';
|
||||
import { SwapStateName } from 'models/rpcModel';
|
||||
import {
|
||||
isSwapStateBtcPunished,
|
||||
isSwapStateBtcRefunded,
|
||||
isSwapStateXmrRedeemInMempool,
|
||||
SwapStateProcessExited,
|
||||
} from '../../../../../../models/storeModel';
|
||||
import XmrRedeemInMempoolPage from '../done/XmrRedeemInMempoolPage';
|
||||
import BitcoinPunishedPage from '../done/BitcoinPunishedPage';
|
||||
// eslint-disable-next-line import/no-cycle
|
||||
import SwapStatePage from '../SwapStatePage';
|
||||
import BitcoinRefundedPage from '../done/BitcoinRefundedPage';
|
||||
import ProcessExitedAndNotDonePage from './ProcessExitedAndNotDonePage';
|
||||
|
||||
type ProcessExitedPageProps = {
|
||||
state: SwapStateProcessExited;
|
||||
};
|
||||
|
||||
export default function ProcessExitedPage({ state }: ProcessExitedPageProps) {
|
||||
const swap = useActiveSwapInfo();
|
||||
|
||||
// If we have a swap state, for a "done" state we should use it to display additional information that can't be extracted from the database
|
||||
if (
|
||||
isSwapStateXmrRedeemInMempool(state.prevState) ||
|
||||
isSwapStateBtcRefunded(state.prevState) ||
|
||||
isSwapStateBtcPunished(state.prevState)
|
||||
) {
|
||||
return <SwapStatePage swapState={state.prevState} />;
|
||||
}
|
||||
|
||||
// If we don't have a swap state for a "done" state, we should fall back to using the database to display as much information as we can
|
||||
if (swap) {
|
||||
if (swap.stateName === SwapStateName.XmrRedeemed) {
|
||||
return <XmrRedeemInMempoolPage state={null} />;
|
||||
}
|
||||
if (swap.stateName === SwapStateName.BtcRefunded) {
|
||||
return <BitcoinRefundedPage state={null} />;
|
||||
}
|
||||
if (swap.stateName === SwapStateName.BtcPunished) {
|
||||
return <BitcoinPunishedPage />;
|
||||
}
|
||||
}
|
||||
|
||||
// If the swap is not a "done" state (or we don't have a db state because the swap did complete the SwapSetup yet) we should tell the user and show logs
|
||||
return <ProcessExitedAndNotDonePage state={state} />;
|
||||
}
|
@ -0,0 +1,5 @@
|
||||
import CircularProgressWithSubtitle from '../../CircularProgressWithSubtitle';
|
||||
|
||||
export default function BitcoinCancelledPage() {
|
||||
return <CircularProgressWithSubtitle description="Refunding your Bitcoin" />;
|
||||
}
|
@ -0,0 +1,38 @@
|
||||
import { Box, DialogContentText } from '@material-ui/core';
|
||||
import { SwapStateBtcLockInMempool } from 'models/storeModel';
|
||||
import BitcoinTransactionInfoBox from '../../BitcoinTransactionInfoBox';
|
||||
import SwapMightBeCancelledAlert from '../../../../alert/SwapMightBeCancelledAlert';
|
||||
|
||||
type BitcoinLockTxInMempoolPageProps = {
|
||||
state: SwapStateBtcLockInMempool;
|
||||
};
|
||||
|
||||
export default function BitcoinLockTxInMempoolPage({
|
||||
state,
|
||||
}: BitcoinLockTxInMempoolPageProps) {
|
||||
return (
|
||||
<Box>
|
||||
<SwapMightBeCancelledAlert
|
||||
bobBtcLockTxConfirmations={state.bobBtcLockTxConfirmations}
|
||||
/>
|
||||
<DialogContentText>
|
||||
The Bitcoin lock transaction has been published. The swap will proceed
|
||||
once the transaction is confirmed and the swap provider locks their
|
||||
Monero.
|
||||
</DialogContentText>
|
||||
<BitcoinTransactionInfoBox
|
||||
title="Bitcoin Lock Transaction"
|
||||
txId={state.bobBtcLockTxId}
|
||||
loading
|
||||
additionalContent={
|
||||
<>
|
||||
Most swap providers require one confirmation before locking their
|
||||
Monero
|
||||
<br />
|
||||
Confirmations: {state.bobBtcLockTxConfirmations}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
@ -0,0 +1,5 @@
|
||||
import CircularProgressWithSubtitle from '../../CircularProgressWithSubtitle';
|
||||
|
||||
export default function BitcoinRedeemedPage() {
|
||||
return <CircularProgressWithSubtitle description="Redeeming your Monero" />;
|
||||
}
|
@ -0,0 +1,7 @@
|
||||
import CircularProgressWithSubtitle from '../../CircularProgressWithSubtitle';
|
||||
|
||||
export default function ReceivedQuotePage() {
|
||||
return (
|
||||
<CircularProgressWithSubtitle description="Exchanging keys, zero-knowledge proofs and generating multi-signature addresses" />
|
||||
);
|
||||
}
|
@ -0,0 +1,16 @@
|
||||
import { SwapStateStarted } from 'models/storeModel';
|
||||
import { BitcoinAmount } from 'renderer/components/other/Units';
|
||||
import CircularProgressWithSubtitle from '../../CircularProgressWithSubtitle';
|
||||
|
||||
export default function StartedPage({ state }: { state: SwapStateStarted }) {
|
||||
const description = state.txLockDetails ? (
|
||||
<>
|
||||
Locking <BitcoinAmount amount={state.txLockDetails.amount} /> with a
|
||||
network fee of <BitcoinAmount amount={state.txLockDetails.fees} />
|
||||
</>
|
||||
) : (
|
||||
'Locking Bitcoin'
|
||||
);
|
||||
|
||||
return <CircularProgressWithSubtitle description={description} />;
|
||||
}
|
@ -0,0 +1,7 @@
|
||||
import CircularProgressWithSubtitle from '../../CircularProgressWithSubtitle';
|
||||
|
||||
export function SyncingMoneroWalletPage() {
|
||||
return (
|
||||
<CircularProgressWithSubtitle description="Syncing Monero wallet with blockchain, this might take a while..." />
|
||||
);
|
||||
}
|
@ -0,0 +1,29 @@
|
||||
import { Box, DialogContentText } from '@material-ui/core';
|
||||
import { SwapStateXmrLockInMempool } from 'models/storeModel';
|
||||
import MoneroTransactionInfoBox from '../../MoneroTransactionInfoBox';
|
||||
|
||||
type XmrLockTxInMempoolPageProps = {
|
||||
state: SwapStateXmrLockInMempool;
|
||||
};
|
||||
|
||||
export default function XmrLockTxInMempoolPage({
|
||||
state,
|
||||
}: XmrLockTxInMempoolPageProps) {
|
||||
const additionalContent = `Confirmations: ${state.aliceXmrLockTxConfirmations}/10`;
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<DialogContentText>
|
||||
They have published their Monero lock transaction. The swap will proceed
|
||||
once the transaction has been confirmed.
|
||||
</DialogContentText>
|
||||
|
||||
<MoneroTransactionInfoBox
|
||||
title="Monero Lock Transaction"
|
||||
txId={state.aliceXmrLockTxId}
|
||||
additionalContent={additionalContent}
|
||||
loading
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
@ -0,0 +1,7 @@
|
||||
import CircularProgressWithSubtitle from '../../CircularProgressWithSubtitle';
|
||||
|
||||
export default function XmrLockedPage() {
|
||||
return (
|
||||
<CircularProgressWithSubtitle description="Waiting for them to redeem the Bitcoin" />
|
||||
);
|
||||
}
|
@ -0,0 +1,91 @@
|
||||
import { useState } from 'react';
|
||||
import { Box, makeStyles, TextField, Typography } from '@material-ui/core';
|
||||
import { SwapStateWaitingForBtcDeposit } from 'models/storeModel';
|
||||
import { useAppSelector } from 'store/hooks';
|
||||
import { satsToBtc } from 'utils/conversionUtils';
|
||||
import { MoneroAmount } from '../../../../other/Units';
|
||||
|
||||
const MONERO_FEE = 0.000016;
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
outer: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: theme.spacing(1),
|
||||
},
|
||||
textField: {
|
||||
'& input::-webkit-outer-spin-button, & input::-webkit-inner-spin-button': {
|
||||
display: 'none',
|
||||
},
|
||||
'& input[type=number]': {
|
||||
MozAppearance: 'textfield',
|
||||
},
|
||||
maxWidth: theme.spacing(16),
|
||||
},
|
||||
}));
|
||||
|
||||
function calcBtcAmountWithoutFees(amount: number, fees: number) {
|
||||
return amount - fees;
|
||||
}
|
||||
|
||||
export default function DepositAmountHelper({
|
||||
state,
|
||||
}: {
|
||||
state: SwapStateWaitingForBtcDeposit;
|
||||
}) {
|
||||
const classes = useStyles();
|
||||
const [amount, setAmount] = useState(state.minDeposit);
|
||||
const bitcoinBalance = useAppSelector((s) => s.rpc.state.balance) || 0;
|
||||
|
||||
function getTotalAmountAfterDeposit() {
|
||||
return amount + satsToBtc(bitcoinBalance);
|
||||
}
|
||||
|
||||
function hasError() {
|
||||
return (
|
||||
amount < state.minDeposit ||
|
||||
getTotalAmountAfterDeposit() > state.maximumAmount
|
||||
);
|
||||
}
|
||||
|
||||
function calcXMRAmount(): number | null {
|
||||
if (Number.isNaN(amount)) return null;
|
||||
if (hasError()) return null;
|
||||
if (state.price == null) return null;
|
||||
|
||||
console.log(
|
||||
`Calculating calcBtcAmountWithoutFees(${getTotalAmountAfterDeposit()}, ${
|
||||
state.minBitcoinLockTxFee
|
||||
}) / ${state.price} - ${MONERO_FEE}`,
|
||||
);
|
||||
|
||||
return (
|
||||
calcBtcAmountWithoutFees(
|
||||
getTotalAmountAfterDeposit(),
|
||||
state.minBitcoinLockTxFee,
|
||||
) /
|
||||
state.price -
|
||||
MONERO_FEE
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box className={classes.outer}>
|
||||
<Typography variant="subtitle2">
|
||||
Depositing {bitcoinBalance > 0 && <>another</>}
|
||||
</Typography>
|
||||
<TextField
|
||||
error={hasError()}
|
||||
value={amount}
|
||||
onChange={(e) => setAmount(parseFloat(e.target.value))}
|
||||
size="small"
|
||||
type="number"
|
||||
className={classes.textField}
|
||||
/>
|
||||
<Typography variant="subtitle2">
|
||||
BTC will give you approximately{' '}
|
||||
<MoneroAmount amount={calcXMRAmount()} />.
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
@ -0,0 +1,14 @@
|
||||
import CircularProgressWithSubtitle from '../../CircularProgressWithSubtitle';
|
||||
import { MoneroWalletRpcUpdateState } from '../../../../../../models/storeModel';
|
||||
|
||||
export default function DownloadingMoneroWalletRpcPage({
|
||||
updateState,
|
||||
}: {
|
||||
updateState: MoneroWalletRpcUpdateState;
|
||||
}) {
|
||||
return (
|
||||
<CircularProgressWithSubtitle
|
||||
description={`Updating monero-wallet-rpc (${updateState.progress}) `}
|
||||
/>
|
||||
);
|
||||
}
|
82
src/renderer/components/modal/swap/pages/init/InitPage.tsx
Normal file
@ -0,0 +1,82 @@
|
||||
import { Box, DialogContentText, makeStyles } from '@material-ui/core';
|
||||
import { useState } from 'react';
|
||||
import BitcoinAddressTextField from 'renderer/components/inputs/BitcoinAddressTextField';
|
||||
import MoneroAddressTextField from 'renderer/components/inputs/MoneroAddressTextField';
|
||||
import { useAppSelector } from 'store/hooks';
|
||||
import PlayArrowIcon from '@material-ui/icons/PlayArrow';
|
||||
import { isTestnet } from 'store/config';
|
||||
import RemainingFundsWillBeUsedAlert from '../../../../alert/RemainingFundsWillBeUsedAlert';
|
||||
import IpcInvokeButton from '../../../../IpcInvokeButton';
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
initButton: {
|
||||
marginTop: theme.spacing(1),
|
||||
},
|
||||
fieldsOuter: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: theme.spacing(2),
|
||||
},
|
||||
}));
|
||||
|
||||
export default function InitPage() {
|
||||
const classes = useStyles();
|
||||
const [redeemAddress, setRedeemAddress] = useState(
|
||||
''
|
||||
);
|
||||
const [refundAddress, setRefundAddress] = useState(
|
||||
''
|
||||
);
|
||||
const [redeemAddressValid, setRedeemAddressValid] = useState(false);
|
||||
const [refundAddressValid, setRefundAddressValid] = useState(false);
|
||||
const selectedProvider = useAppSelector(
|
||||
(state) => state.providers.selectedProvider,
|
||||
);
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<RemainingFundsWillBeUsedAlert />
|
||||
<DialogContentText>
|
||||
Please specify the address to which the Monero should be sent upon
|
||||
completion of the swap and the address for receiving a Bitcoin refund if
|
||||
the swap fails.
|
||||
</DialogContentText>
|
||||
|
||||
<Box className={classes.fieldsOuter}>
|
||||
<MoneroAddressTextField
|
||||
label="Monero redeem address"
|
||||
address={redeemAddress}
|
||||
onAddressChange={setRedeemAddress}
|
||||
onAddressValidityChange={setRedeemAddressValid}
|
||||
helperText="The monero will be sent to this address"
|
||||
fullWidth
|
||||
/>
|
||||
|
||||
<BitcoinAddressTextField
|
||||
label="Bitcoin refund address"
|
||||
address={refundAddress}
|
||||
onAddressChange={setRefundAddress}
|
||||
onAddressValidityChange={setRefundAddressValid}
|
||||
helperText="In case something goes terribly wrong, all Bitcoin will be refunded to this address"
|
||||
fullWidth
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<IpcInvokeButton
|
||||
disabled={
|
||||
!refundAddressValid || !redeemAddressValid || !selectedProvider
|
||||
}
|
||||
variant="contained"
|
||||
color="primary"
|
||||
size="large"
|
||||
className={classes.initButton}
|
||||
endIcon={<PlayArrowIcon />}
|
||||
ipcChannel="spawn-buy-xmr"
|
||||
ipcArgs={[selectedProvider, redeemAddress, refundAddress]}
|
||||
displayErrorSnackbar={false}
|
||||
>
|
||||
Start swap
|
||||
</IpcInvokeButton>
|
||||
</Box>
|
||||
);
|
||||
}
|
@ -0,0 +1,21 @@
|
||||
import { useAppSelector } from 'store/hooks';
|
||||
import { SwapSpawnType } from 'models/cliModel';
|
||||
import CircularProgressWithSubtitle from '../../CircularProgressWithSubtitle';
|
||||
|
||||
export default function InitiatedPage() {
|
||||
const description = useAppSelector((s) => {
|
||||
switch (s.swap.spawnType) {
|
||||
case SwapSpawnType.INIT:
|
||||
return 'Requesting quote from provider...';
|
||||
case SwapSpawnType.RESUME:
|
||||
return 'Resuming swap...';
|
||||
case SwapSpawnType.CANCEL_REFUND:
|
||||
return 'Attempting to cancel & refund swap...';
|
||||
default:
|
||||
// Should never be hit
|
||||
return 'Initiating swap...';
|
||||
}
|
||||
});
|
||||
|
||||
return <CircularProgressWithSubtitle description={description} />;
|
||||
}
|
@ -0,0 +1,86 @@
|
||||
import { Box, makeStyles, Typography } from '@material-ui/core';
|
||||
import { SwapStateWaitingForBtcDeposit } from 'models/storeModel';
|
||||
import { useAppSelector } from 'store/hooks';
|
||||
import DepositAddressInfoBox from '../../DepositAddressInfoBox';
|
||||
import BitcoinIcon from '../../../../icons/BitcoinIcon';
|
||||
import DepositAmountHelper from './DepositAmountHelper';
|
||||
import {
|
||||
BitcoinAmount,
|
||||
MoneroBitcoinExchangeRate,
|
||||
SatsAmount,
|
||||
} from '../../../../other/Units';
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
amountHelper: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
},
|
||||
additionalContent: {
|
||||
paddingTop: theme.spacing(1),
|
||||
gap: theme.spacing(0.5),
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
},
|
||||
}));
|
||||
|
||||
type WaitingForBtcDepositPageProps = {
|
||||
state: SwapStateWaitingForBtcDeposit;
|
||||
};
|
||||
|
||||
export default function WaitingForBtcDepositPage({
|
||||
state,
|
||||
}: WaitingForBtcDepositPageProps) {
|
||||
const classes = useStyles();
|
||||
const bitcoinBalance = useAppSelector((s) => s.rpc.state.balance) || 0;
|
||||
|
||||
// TODO: Account for BTC lock tx fees
|
||||
return (
|
||||
<Box>
|
||||
<DepositAddressInfoBox
|
||||
title="Bitcoin Deposit Address"
|
||||
address={state.depositAddress}
|
||||
additionalContent={
|
||||
<Box className={classes.additionalContent}>
|
||||
<Typography variant="subtitle2">
|
||||
<ul>
|
||||
{bitcoinBalance > 0 ? (
|
||||
<li>
|
||||
You have already deposited{' '}
|
||||
<SatsAmount amount={bitcoinBalance} />
|
||||
</li>
|
||||
) : null}
|
||||
<li>
|
||||
Send any amount between{' '}
|
||||
<BitcoinAmount amount={state.minDeposit} /> and{' '}
|
||||
<BitcoinAmount amount={state.maxDeposit} /> to the address
|
||||
above
|
||||
{bitcoinBalance > 0 && (
|
||||
<> (on top of the already deposited funds)</>
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
All Bitcoin sent to this this address will converted into
|
||||
Monero at an exchance rate of{' '}
|
||||
<MoneroBitcoinExchangeRate rate={state.price} />
|
||||
</li>
|
||||
<li>
|
||||
The network fee of{' '}
|
||||
<BitcoinAmount amount={state.minBitcoinLockTxFee} /> will
|
||||
automatically be deducted from the deposited coins
|
||||
</li>
|
||||
<li>
|
||||
The swap will start automatically as soon as the minimum
|
||||
amount is deposited
|
||||
</li>
|
||||
</ul>
|
||||
</Typography>
|
||||
<DepositAmountHelper
|
||||
state={state}
|
||||
/>
|
||||
</Box>
|
||||
}
|
||||
icon={<BitcoinIcon />}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
34
src/renderer/components/modal/wallet/WithdrawDialog.tsx
Normal file
@ -0,0 +1,34 @@
|
||||
import { Dialog } from '@material-ui/core';
|
||||
import { useAppDispatch, useIsRpcEndpointBusy } from 'store/hooks';
|
||||
import { RpcMethod } from 'models/rpcModel';
|
||||
import { rpcResetWithdrawTxId } from 'store/features/rpcSlice';
|
||||
import WithdrawStatePage from './WithdrawStatePage';
|
||||
import DialogHeader from '../DialogHeader';
|
||||
|
||||
export default function WithdrawDialog({
|
||||
open,
|
||||
onClose,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const isRpcEndpointBusy = useIsRpcEndpointBusy(RpcMethod.WITHDRAW_BTC);
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
function onCancel() {
|
||||
if (!isRpcEndpointBusy) {
|
||||
onClose();
|
||||
dispatch(rpcResetWithdrawTxId());
|
||||
}
|
||||
}
|
||||
|
||||
// This prevents an issue where the Dialog is shown for a split second without a present withdraw state
|
||||
if (!open && !isRpcEndpointBusy) return null;
|
||||
|
||||
return (
|
||||
<Dialog open onClose={onCancel} maxWidth="sm" fullWidth>
|
||||
<DialogHeader title="Withdraw Bitcoin" />
|
||||
<WithdrawStatePage onCancel={onCancel} />
|
||||
</Dialog>
|
||||
);
|
||||
}
|
@ -0,0 +1,27 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { Box, DialogContent, makeStyles } from '@material-ui/core';
|
||||
import WithdrawStepper from './WithdrawStepper';
|
||||
|
||||
const useStyles = makeStyles({
|
||||
outer: {
|
||||
minHeight: '15rem',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
});
|
||||
|
||||
export default function WithdrawDialogContent({
|
||||
children,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const classes = useStyles();
|
||||
|
||||
return (
|
||||
<DialogContent dividers className={classes.outer}>
|
||||
<Box>{children}</Box>
|
||||
<WithdrawStepper />
|
||||
</DialogContent>
|
||||
);
|
||||
}
|