This commit is contained in:
dd 2020-12-20 11:56:41 -05:00
parent ac9a6550cf
commit b3e1ef36b0
100 changed files with 29283 additions and 0 deletions

47
Makefile Normal file
View File

@ -0,0 +1,47 @@
all: cli/contract_keys.json
echo "all done"
.PHONY: clean
clean:
rm -rf .env
keypair = ~/.config/solana/id.json
.env/keypair:
mkdir -p .env
solana config set --url http://arka.fm:8899
solana-keygen new --force --no-passphrase
solana airdrop 1000
touch $@
.env/program_id: .env/keypair program/Cargo* program/src/*
-rm -f $@ # remove outdated program_id to detect if solana deploy failed
cd program && cargo build-bpf
solana deploy program/target/deploy/omega.so | jq .programId -r >$@
test -f $@ # verify program was successfully deployed
.env/quote_mint: .env/keypair
spl-token create-token | head -n 1 | cut -d' ' -f3 >$@
spl-token create-account `cat $@` | head -n 1 | cut -d' ' -f3 >.env/quote_account
spl-token mint `cat $@` 1000000
# send minted tokens to dev wallets
solana airdrop 1000 3rnRtxMkaPDeoYwdVLZPzRnGoD8z4zfk25pLiERVbBWY
spl-token transfer --fund-recipient `cat .env/quote_account` 300000 3rnRtxMkaPDeoYwdVLZPzRnGoD8z4zfk25pLiERVbBWY
solana airdrop 1000 FJpmfVUmd75kVieMjBLixdk5611xvXVUNadhcSbhE4Hm
spl-token transfer --fund-recipient `cat .env/quote_account` 300000 FJpmfVUmd75kVieMjBLixdk5611xvXVUNadhcSbhE4Hm
cli/contract_keys.json: .env/program_id .env/quote_mint cli/Cargo* cli/src/*
cd cli && cargo run init-omega-contract \
--payer $(keypair) \
--omega-program-id `cat ../.env/program_id` \
--oracle `solana address` \
--quote-mint `cat ../.env/quote_mint` \
--num-outcomes 2 \
--outcome-names YES NO \
--contract-name "TRUMPFEB" \
--details "Resolution: Donald Trump is the President of the United States at 2021-02-01 00:00:00 UTC. Each YES token will be redeemable for 1 USDC if the resolution is true and 0 otherwise. Similarly, each NO token will be redeemable for 1 USDC if the resolution is false. The oracle will resolve this contract before 2021-02-08 00:00:00 UTC in the same way as the TRUMPFEB token at ftx.com." \
--exp-time "2020-02-01 00:00:00" \
--contract-keys-path "../ui/src/contract_keys.json"

4393
cli/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

24
cli/Cargo.toml Normal file
View File

@ -0,0 +1,24 @@
[package]
name = "cli"
version = "0.2.0"
description = "CLI for Omega Predictions Protocol"
authors = ["Blockworks <hello@blockworks.foundation>"]
license = "Apache-2.0"
edition = "2018"
[[bin]]
name = "cli"
path = "src/main.rs"
[dependencies]
client = { path = "../client" }
anyhow = "1.0.36"
clap = "3.0.0-beta.2"
solana-client = "1.4.17"
solana-cli = "1.4.17"
solana-sdk = "1.4.17"
omega = { version = "0.2.0", path = "../program", features=["no-entrypoint"] }
spl-token = { version = "^3.0", features=["no-entrypoint"] }
spl-token-swap = { version = "*", git = "https://github.com/solana-labs/solana-program-library.git", features=["no-entrypoint"]}
serde_json = "1.0.60"
chrono = "0.4.19"

19
cli/demsen.sh Normal file
View File

@ -0,0 +1,19 @@
CLUSTER=devnet
cd ~/omega/program || exit
cargo build-bpf
OMEGA_PROGRAM_ID="$(solana deploy target/deploy/omega.so | jq .programId -r)"
cd ../cli
KEYPAIR=~/.config/solana/id.json
MY_ADDR="$(solana address)"
QUOTE_MINT=Fq939Y5hycK62ZGwBjftLY2VyxqAQ8f1MxRqBMdAaBS7
CONTRACT_NAME=DEMSEN
OUTCOME_NAMES="YES NO"
DETAILS="Resolution: The number of U.S. senators who were elected with a ballot-listed or otherwise identifiable affiliation with, or who have publicly stated an intention to caucus with the Democratic party shall be greater than or equal to 50 if the Vice President is affiliated with the Democratic party or greater than 50 otherwise. The YES tokens can be redeemed for 1 USDC if the resolution is true at 2021-02-01 00:00:00 UTC and the NO tokens can be redeemed for 1 USDC otherwise. This contract will be resolved in the same way as the Democratic contract on PredictIt at this URL: https://www.predictit.org/markets/detail/4366"
CONTRACT_KEYS_PATH="../ui/src/contract_keys.json"
ICON_URLS="https://az620379.vo.msecnd.net/images/Contracts/small_29b55b5a-6faf-4041-8b21-ab27421d0ade.png https://az620379.vo.msecnd.net/images/Contracts/small_77aea45d-8c93-46d6-b338-43a6af0ba8e1.png"
cargo run -- $CLUSTER init-omega-contract --payer $KEYPAIR --omega-program-id $OMEGA_PROGRAM_ID --oracle $MY_ADDR \
--quote-mint $QUOTE_MINT --num-outcomes 2 --outcome-names $OUTCOME_NAMES --contract-name $CONTRACT_NAME \
--details "$DETAILS" --exp-time "2021-02-01 00:00:00" --contract-keys-path $CONTRACT_KEYS_PATH --icon-urls $ICON_URLS

340
cli/src/main.rs Normal file
View File

@ -0,0 +1,340 @@
use std::fs::File;
use std::mem::size_of;
use std::str::FromStr;
use anyhow::Result;
use chrono::NaiveDateTime;
use clap::Clap;
use client::utils::{create_account_rent_exempt, create_and_init_mint, create_signer_key_and_nonce, create_token_account, get_account, mnemonic_to_keypair, read_keypair_file, send_instructions, Cluster};
use omega::instruction::{init_omega_contract, resolve};
use omega::state::{DETAILS_BUFFER_LEN, OmegaContract};
use serde_json::{json, Value};
use solana_client::rpc_client::RpcClient;
use solana_sdk::pubkey::Pubkey;
use solana_sdk::signature::{Keypair, Signer, write_keypair_file};
use spl_token::state::Mint;
use solana_sdk::commitment_config::CommitmentConfig;
#[derive(Clap, Debug)]
pub struct Opts {
#[clap(default_value = "devnet")]
pub cluster: Cluster,
#[clap(subcommand)]
pub command: Command,
}
#[derive(Clap, Debug)]
pub enum Command {
InitOmegaContract {
#[clap(long, short)]
payer: String,
#[clap(long)]
omega_program_id: String,
#[clap(long)]
oracle: String,
#[clap(long, short)]
quote_mint: String,
#[clap(long, short)]
num_outcomes: usize,
#[clap(long, short)]
contract_keys_path: String,
#[clap(long)]
contract_name: String,
#[clap(long)]
outcome_names: Vec<String>,
#[clap(long)]
details: String,
#[clap(long)]
exp_time: String,
#[clap(long)]
icon_urls: Option<Vec<String>>
},
IssueSet {
#[clap(long, short, default_value="~/.config/solana/id.json")]
payer: String,
#[clap(long, short)]
contract_keys_path: String,
#[clap(long, short)]
user: Option<String>
},
Resolve {
#[clap(long, short)]
payer: String,
#[clap(long, short)]
contract_keys_path: String,
#[clap(long, short)]
oracle_keypair: String,
#[clap(long, short)]
winner: String,
},
SolletToLocal {
#[clap(long, short, default_value="~/.config/solana/sollet.json")]
keypair_path: String,
#[clap(long, short)]
sollet_mnemonic: Vec<String>,
#[clap(long, short)]
passphrase: Option<String>,
},
CreateSwapPools {
#[clap(long, short)]
payer: String,
#[clap(long, short)]
contract_keys_path: String,
#[clap(long, short)]
swap_program_id: String
},
}
impl Opts {
fn client(&self) -> RpcClient {
RpcClient::new_with_commitment(self.cluster.url().to_string(), CommitmentConfig::single_gossip())
}
}
#[allow(unused_variables)]
pub fn start(opts: Opts) -> Result<()> {
let client = opts.client();
match opts.command {
Command::InitOmegaContract {
payer,
omega_program_id,
oracle,
quote_mint,
num_outcomes,
contract_keys_path,
contract_name,
outcome_names,
details,
exp_time,
icon_urls
} => {
println!("InitOmegaContract");
assert_eq!(num_outcomes, outcome_names.len());
assert!(details.len() <= DETAILS_BUFFER_LEN);
let icon_urls: Vec<String> = match icon_urls {
None => { vec![
String::from("https://ftx.com/static/media/trumphappy.055aa6c3.svg"),
String::from("https://ftx.com/static/media/trumpsad.5f8806cd.svg")
] }
Some(v) => v
};
assert_eq!(icon_urls.len(), num_outcomes);
let payer = read_keypair_file(payer.as_str())?;
let omega_program_id = Pubkey::from_str(omega_program_id.as_str())?;
let oracle_pk = Pubkey::from_str(oracle.as_str())?;
let quote_mint_pk = Pubkey::from_str(quote_mint.as_str())?;
let omega_contract_pk = create_account_rent_exempt(
&client, &payer, size_of::<OmegaContract>(), &omega_program_id
)?.pubkey();
let (signer_key, signer_nonce) = create_signer_key_and_nonce(&omega_program_id, &omega_contract_pk);
let quote_vault_pk = create_token_account(&client, &quote_mint_pk, &signer_key, &payer)?.pubkey();
let quote_mint: Mint = get_account(&client, &quote_mint_pk)?;
let mut outcome_infos = Vec::<Value>::new();
let mut outcome_mint_pks = vec![];
for i in 0..num_outcomes {
let outcome_mint_kp = Keypair::new();
create_and_init_mint(
&client,
&payer,
&outcome_mint_kp,
&signer_key,
quote_mint.decimals
)?;
let outcome_json = json!(
{
"mint_pk": outcome_mint_kp.pubkey().to_string(),
"name": outcome_names[i],
"icon": icon_urls[i].clone()
}
);
outcome_infos.push(outcome_json);
outcome_mint_pks.push(outcome_mint_kp.pubkey());
}
let exp_time = NaiveDateTime::parse_from_str(exp_time.as_str(), "%Y-%m-%d %H:%M:%S")?;
let exp_time = exp_time.timestamp() as u64;
let instruction = init_omega_contract(
&omega_program_id,
&omega_contract_pk,
&oracle_pk,
&quote_mint_pk,
&quote_vault_pk,
&signer_key,
outcome_mint_pks.as_slice(),
exp_time,
signer_nonce,
details.as_str()
)?;
let instructions = vec![instruction];
let signers = vec![&payer];
send_instructions(&client, instructions, signers, &payer.pubkey())?;
let contract_keys = json!({
"contract_name": contract_name,
"omega_program_id": omega_program_id.to_string(),
"omega_contract_pk": omega_contract_pk.to_string(),
"oracle_pk": oracle_pk.to_string(),
"quote_mint_pk": quote_mint_pk.to_string(),
"quote_vault_pk": quote_vault_pk.to_string(),
"signer_pk": signer_key.to_string(),
"signer_nonce": signer_nonce,
"outcomes": outcome_infos,
"details": details
});
let f = File::create(&contract_keys_path).unwrap();
serde_json::to_writer_pretty(&f, &contract_keys).unwrap();
println!("contract keys were written into: {}", contract_keys_path);
}
Command::IssueSet { .. } => {
println!("IssueSet");
unimplemented!()
}
Command::Resolve {
payer,
contract_keys_path,
oracle_keypair,
winner
} => {
println!("Resolve");
let payer = read_keypair_file(payer.as_str())?;
let oracle_keypair = read_keypair_file(oracle_keypair.as_str())?;
let contract_keys: Value = serde_json::from_reader(File::open(contract_keys_path)?)?;
let winner_pk = Pubkey::from_str(winner.as_str())?;
let omega_program_id = Pubkey::from_str(contract_keys["omega_program_id"].as_str().unwrap())?;
let omega_contract_pk = Pubkey::from_str(contract_keys["omega_contract_pk"].as_str().unwrap())?;
let instruction = resolve(
&omega_program_id,
&omega_contract_pk,
&oracle_keypair.pubkey(),
&winner_pk
)?;
let instructions = vec![instruction];
let mut signers = vec![&payer];
if oracle_keypair != payer {
signers.push(&oracle_keypair)
}
send_instructions(&client, instructions, signers, &payer.pubkey())?;
}
Command::SolletToLocal {
keypair_path,
sollet_mnemonic,
passphrase
} => {
let derive_path = "m/501'/0'/0/0";
let sollet_mnemonic: String = sollet_mnemonic.join(" ");
let passphrase = passphrase.unwrap_or(String::from(""));
let kp = mnemonic_to_keypair(
sollet_mnemonic.as_str(),
passphrase.as_str(),
derive_path
)?;
write_keypair_file(&kp, keypair_path.as_str()).unwrap();
}
Command::CreateSwapPools {
payer,
contract_keys_path,
swap_program_id
} => {
unimplemented!()
// Create two swap pools based on contract keys, fund them with initial liquidity
// let payer = read_keypair_file(payer.as_str())?;
// let swap_program_id = Pubkey::from_str(swap_program_id.as_str())?;
// let contract_keys: Value = serde_json::from_reader(File::open(Path::new(contract_keys_path.as_str())).unwrap())?;
//
// let swap_kp = Keypair::new();
// let swap_pk = swap_kp.pubkey();
// let create_swap_instr = create_account_instr(
// &client, &payer, &swap_kp,
// spl_token_swap::state::SwapInfo::get_packed_len(),
// &swap_program_id
// )?;
//
// let (swap_auth_pk, nonce) = u8::create_signer_key_and_nonce(&swap_program_id, &swap_pk);
//
//
// let outcome_pk = Pubkey::from_str(contract_keys["outcomes"][0]["mint_pk"].as_str().unwrap())?;
// let quote_mint_pk = Pubkey::from_str(contract_keys["quote_mint_pk"].as_str().unwrap())?;
// // let accounts: Vec<RpcKeyedAccount> = client.get_token_accounts_by_owner(&payer.pubkey(), TokenAccountsFilter::Mint(outcome_pk.clone()))?;
// // for acc in accounts {
// // println!("{}", acc.pubkey);
// // }
// // panic!();
// let outcome_wallet_kp = create_token_account(&client, &outcome_pk, &swap_auth_pk, &payer)?;
// let quote_wallet_kp = create_token_account(&client, &quote_mint_pk, &swap_auth_pk, &payer)?;
// let outcome_wallet_pk = outcome_wallet_kp.pubkey();
// let quote_wallet_pk = quote_wallet_kp.pubkey();
//
// let lp_mint_kp = Keypair::new();
// let lp_mint_pk = lp_mint_kp.pubkey();
// create_and_init_mint(&client, &payer, &lp_mint_kp, &swap_auth_pk, 9)?;
//
// let fee_acc_kp = create_token_account(&client, &lp_mint_pk, &payer.pubkey(), &payer)?;
// let fee_acc_pk = fee_acc_kp.pubkey();
//
// let payer_outcome_wallet_pk = Pubkey::from_str("CqUbC9APNS5WsA26aPy1w3R5ArcUnFDPmkTuCdYBUgju")?;
// let payer_quote_wallet_pk = Pubkey::from_str("Ggh42YAn4oUcBWbLc3orbqJq1BWjqs8VaGzwAD5f8Vbb")?;
// let transfer0 = spl_token::instruction::transfer(
// &spl_token::id(),
// &payer_outcome_wallet_pk,
// &outcome_wallet_pk,
// &payer.pubkey(),
// &[],
// 1_000_000_000
// )?;
// let transfer1 = spl_token::instruction::transfer(
// &spl_token::id(),
// &payer_quote_wallet_pk,
// &quote_wallet_pk,
// &payer.pubkey(),
// &[],
// 1_000_000_000
// )?;
// let swap_init_instruction = spl_token_swap::instruction::initialize(
// &swap_program_id,
// &spl_token::id(),
// &swap_pk,
// &swap_auth_pk,
// &outcome_wallet_pk,
// &quote_wallet_pk,
// &lp_mint_pk,
// &fee_acc_pk,
// &fee_acc_pk,
// nonce,
// SwapCurve::default()
// )?;
//
// let instructions = vec![transfer0, transfer1, create_swap_instr, swap_init_instruction];
// let signers = vec![&payer, &swap_kp];
// send_instructions(&client, instructions, signers, &payer.pubkey())?;
// println!("finished");
}
}
Ok(())
}
fn main() {
let opts = Opts::parse();
start(opts).unwrap();
}

2361
program/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

34
program/Cargo.toml Normal file
View File

@ -0,0 +1,34 @@
[package]
name = "omega"
version = "0.2.0"
description = "Omega Predictions Protocol"
authors = ["Blockworks <hello@blockworks.foundation>"]
license = "Apache-2.0"
edition = "2018"
[features]
no-entrypoint = []
[dependencies]
solana-program = "1.4.17"
spl-token = { version = "^3.0.0", features=["no-entrypoint"] }
byteorder = "1.3.4"
arrayref = "0.3.6"
num_enum = "0.5.1"
bytemuck = "1.4.1"
safe-transmute = "0.11.0"
enumflags2 = "=0.6.4"
static_assertions = "1.1.0"
thiserror = "1.0.22"
serde = "1.0.118"
bincode = "1.3.1"
[dev-dependencies]
solana-sdk = "1.4.17"
[profile.release]
lto = true
[lib]
name = "omega"
crate-type = ["cdylib", "lib"]

2
program/Xargo.toml Normal file
View File

@ -0,0 +1,2 @@
[target.bpfel-unknown-unknown.dependencies.std]
features = []

126
program/src/error.rs Normal file
View File

@ -0,0 +1,126 @@
use num_enum::{FromPrimitive, IntoPrimitive};
use solana_program::program_error::ProgramError;
use thiserror::Error;
pub type OmegaResult<T = ()> = Result<T, OmegaError>;
#[derive(Debug)]
pub struct AssertionError {
pub line: u16,
pub file_id: SourceFileId,
}
impl From<AssertionError> for u32 {
fn from(err: AssertionError) -> u32 {
(err.line as u32) + ((err.file_id as u8 as u32) << 24)
}
}
impl From<AssertionError> for OmegaError {
fn from(err: AssertionError) -> OmegaError {
let err: u32 = err.into();
OmegaError::ProgramError(ProgramError::Custom(err.into()))
}
}
#[derive(Error, Debug, PartialEq, Eq)]
pub enum OmegaError {
#[error(transparent)]
ProgramError(#[from] ProgramError),
#[error("{0:?}")]
ErrorCode(#[from] OmegaErrorCode),
}
#[derive(Debug, IntoPrimitive, FromPrimitive, Clone, Copy, PartialEq, Eq)]
#[repr(u32)]
pub enum OmegaErrorCode {
BorrowError,
InvalidOutcomeMintAuthority,
InvalidWinner,
Unknown = 1000,
// This contains the line number in the lower 16 bits,
// and the source file id in the upper 8 bits
#[num_enum(default)]
AssertionError,
}
#[repr(u8)]
#[derive(Error, Debug)]
pub enum SourceFileId {
#[error("src/processor.rs")]
Processor = 0,
#[error("src/state.rs")]
State = 1,
#[error("src/instruction.rs")]
Instruction = 2,
}
#[macro_export]
macro_rules! declare_check_assert_macros {
($source_file_id:expr) => {
macro_rules! assertion_error {
() => {{
let file_id: SourceFileId = $source_file_id;
$crate::error::AssertionError {
line: line!() as u16,
file_id,
}
}};
}
#[allow(unused_macros)]
macro_rules! check_assert {
($val:expr) => {{
if $val {
Ok(())
} else {
Err(assertion_error!())
}
}};
}
#[allow(unused_macros)]
macro_rules! check_assert_eq {
($a:expr, $b:expr) => {{
if $a == $b {
Ok(())
} else {
Err(assertion_error!())
}
}};
}
#[allow(unused_macros)]
macro_rules! check_unreachable {
() => {{
Err(assertion_error!())
}};
}
};
}
impl std::fmt::Display for OmegaErrorCode {
fn fmt(&self, fmt: &mut std::fmt::Formatter<'_>) -> Result<(), std::fmt::Error> {
<Self as std::fmt::Debug>::fmt(self, fmt)
}
}
impl std::error::Error for OmegaErrorCode {}
impl std::convert::From<OmegaError> for ProgramError {
fn from(e: OmegaError) -> ProgramError {
match e {
OmegaError::ProgramError(e) => e,
OmegaError::ErrorCode(c) => ProgramError::Custom(c.into()),
}
}
}
impl std::convert::From<std::cell::BorrowError> for OmegaError {
fn from(_: std::cell::BorrowError) -> Self {
OmegaError::ErrorCode(OmegaErrorCode::BorrowError)
}
}

313
program/src/instruction.rs Normal file
View File

@ -0,0 +1,313 @@
use arrayref::{array_ref, array_refs};
use serde::{Deserialize, Serialize};
use solana_program::instruction::{AccountMeta, Instruction};
use solana_program::program_error::ProgramError;
use solana_program::pubkey::Pubkey;
#[repr(C)]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub enum OmegaInstruction {
/// Initialize a new omega contract
///
/// Accounts expected by this instruction:
///
/// 0. `[writable]` omega_contract_acc
/// 1. `[]` oracle_acc - pubkey of oracle
/// 2. `[]` Quote currency mint
/// 3. `[]` quote_vault - Quote currency SPL token account owned by Omega program
/// 4. `[]` signer_acc - The account pointed to by signer key
/// 5. `[]` Rent sysvar account
InitOmegaContract {
exp_time: u64,
signer_nonce: u64,
},
/// Issue one of each outcome token for each quote token deposited
///
/// Accounts expected by this instruction:
///
/// 0. `[]` OmegaContract data
/// 1. `[signer]` user's solana account (the owner)
/// 2. `[writable]` user's quote currency wallet
/// 3. `[writable]` omega's quote currency vault
/// 4. `[]` account pointed to by SPL token program id
/// 5. `[]` account pointed to by hashing signer nonce, omega contract pubkey and omega program id
/// 6. `[writable]` outcome0 mint account
/// 7. `[writable]` outcome0 user wallet account
///
/// Repeat 6, 7 for each outcome.
/// Total accounts: 6 + 2 * num_outcomes
IssueSet {
quantity: u64,
},
/// Deposit one of each outcome to receive 1 lot size of quote token
/// Contract will burn these tokens
///
/// Accounts expected by this instruction:
///
/// 0. `[]` omega_contract_acc - OmegaContract data
/// 1. `[signer]` user_acc - user's solana account (the owner)
/// 2. `[writable]` user_quote_acc - user's quote currency wallet
/// 3. `[writable]` vault_acc - omega's quote currency vault
/// 4. `[]` spl_token_program_acc - account pointed to by SPL token program id
/// 5. `[]` omega_signer_acc - account pointed to by hashing signer nonce, omega contract
/// pubkey and omega program id
/// 6. `[writable]` outcome0_mint_acc - outcome0 mint account
/// 7. `[writable]` outcome0_user_acc - user wallet account for outcome0
///
/// Repeat 6, 7 for each outcome.
/// Total accounts: 6 + 2 * num_outcomes
RedeemSet {
quantity: u64
},
/// Deposit winning token to receie 1 lot size of quote token
/// Will fail if contract not yet resolved
///
/// Accounts expected by this instruction:
///
/// 0. `[]` omega_contract_acc - OmegaContract data
/// 1. `[signer]` user_acc - user's solana account (the owner)
/// 2. `[writable]` user_quote_acc - user's quote currency wallet
/// 3. `[writable]` vault_acc - omega's quote currency vault
/// 4. `[]` spl_token_program_acc - account pointed to by SPL token program id
/// 5. `[]` omega_signer_acc - account pointed to by hashing signer nonce, omega contract
/// pubkey and omega program id
/// 6. `[writable]` winner_mint_acc - mint of winning outcome
/// 7. `[writable]` winner_user_acc - user wallet of winning outcome
RedeemWinner {
quantity: u64
},
/// Designated oracle will pick winner
/// This will fail if time < expiration time specified in contract
///
/// Accounts expected by this instruction:
///
/// 0. `[writable]` omega_contract_acc
/// 1. `[signer]` oracle_acc - pubkey of oracle
/// 2. `[]` winner_acc - mint pubkey of winning outcome
/// 3. `[]` clock_acc - sysvar Clock
Resolve
}
impl OmegaInstruction {
/// First four bytes of instruction data is the index of the instruction (e.g. 0 -> InitOmegaContract)
/// Remaining data is the actual instruction contents
pub fn unpack(input: &[u8]) -> Option<Self> {
let (&discrim, data) = array_refs![input, 4; ..;];
let discrim = u32::from_le_bytes(discrim);
Some(match discrim {
0 => {
let data = array_ref![data, 0, 16];
let (exp_time, signer_nonce) = array_refs![data, 8, 8];
OmegaInstruction::InitOmegaContract {
exp_time: u64::from_le_bytes(*exp_time),
signer_nonce: u64::from_le_bytes(*signer_nonce),
}
}
1 => {
let quantity = array_ref![data, 0, 8];
OmegaInstruction::IssueSet {
quantity: u64::from_le_bytes(*quantity)
}
}
2 => {
let quantity = array_ref![data, 0, 8];
OmegaInstruction::RedeemSet {
quantity: u64::from_le_bytes(*quantity)
}
}
3 => {
let quantity = array_ref![data, 0, 8];
OmegaInstruction::RedeemWinner {
quantity: u64::from_le_bytes(*quantity)
}
}
4 => {
OmegaInstruction::Resolve
}
_ => { return None; }
})
}
pub fn pack(&self) -> Vec<u8> {
bincode::serialize(self).unwrap()
}
}
/// The outcome_pks are public keys for the SPL token mint of each outcome
/// Make sure the token has 0 supply and the authority is the key generated by gen_vault_signer_key
/// using the signer nonce
pub fn init_omega_contract(
program_id: &Pubkey,
omega_contract_pk: &Pubkey,
oracle_pk: &Pubkey,
quote_mint_pk: &Pubkey,
vault_pk: &Pubkey,
signer_pk: &Pubkey,
outcome_pks: &[Pubkey],
exp_time: u64,
signer_nonce: u64,
details_str: &str
) -> Result<Instruction, ProgramError> {
let mut accounts = vec![
AccountMeta::new(*omega_contract_pk, false),
AccountMeta::new_readonly(*oracle_pk, false),
AccountMeta::new_readonly(*quote_mint_pk, false),
AccountMeta::new_readonly(*vault_pk, false),
AccountMeta::new_readonly(*signer_pk, false),
AccountMeta::new_readonly(solana_program::sysvar::rent::ID, false),
];
for pk in outcome_pks {
accounts.push(AccountMeta::new(*pk, false));
}
let instr = OmegaInstruction::InitOmegaContract {
exp_time,
signer_nonce
};
let details = details_str.as_bytes();
let mut data = instr.pack();
data.extend_from_slice(details);
Ok(Instruction {
program_id: *program_id,
accounts,
data
})
}
pub fn issue_set(
program_id: &Pubkey,
omega_contract_pk: &Pubkey,
user_pk: &Pubkey,
user_quote_pk: &Pubkey,
vault_pk: &Pubkey,
signer_pk: &Pubkey,
outcome_pks: &[(Pubkey, Pubkey)], // (mint, user_acc)
quantity: u64
) -> Result<Instruction, ProgramError> {
let mut accounts = vec![
AccountMeta::new_readonly(*omega_contract_pk, false),
AccountMeta::new_readonly(*user_pk, true),
AccountMeta::new(*user_quote_pk, false),
AccountMeta::new(*vault_pk, false),
AccountMeta::new_readonly(spl_token::id(), false),
AccountMeta::new_readonly(*signer_pk, false),
];
for (outcome_mint_pk, outcome_user_pk) in outcome_pks {
accounts.push(AccountMeta::new(*outcome_mint_pk, false));
accounts.push(AccountMeta::new(*outcome_user_pk, false));
}
let instr = OmegaInstruction::IssueSet { quantity };
let data = instr.pack();
Ok(Instruction {
program_id: *program_id,
accounts,
data
})
}
pub fn redeem_set(
program_id: &Pubkey,
omega_contract_pk: &Pubkey,
user_pk: &Pubkey,
user_quote_pk: &Pubkey,
vault_pk: &Pubkey,
signer_pk: &Pubkey,
outcome_pks: &[(Pubkey, Pubkey)], // (mint, user_acc)
quantity: u64
) -> Result<Instruction, ProgramError> {
let mut accounts = vec![
AccountMeta::new_readonly(*omega_contract_pk, false),
AccountMeta::new_readonly(*user_pk, true),
AccountMeta::new(*user_quote_pk, false),
AccountMeta::new(*vault_pk, false),
AccountMeta::new_readonly(spl_token::id(), false),
AccountMeta::new_readonly(*signer_pk, false),
];
for (outcome_mint_pk, outcome_user_pk) in outcome_pks {
accounts.push(AccountMeta::new(*outcome_mint_pk, false));
accounts.push(AccountMeta::new(*outcome_user_pk, false));
}
let instr = OmegaInstruction::RedeemSet { quantity };
let data = instr.pack();
Ok(Instruction {
program_id: *program_id,
accounts,
data
})
}
pub fn redeem_winner(
program_id: &Pubkey,
omega_contract_pk: &Pubkey,
user_pk: &Pubkey,
user_quote_pk: &Pubkey,
vault_pk: &Pubkey,
signer_pk: &Pubkey,
winner_mint_pk: &Pubkey,
winner_user_pk: &Pubkey,
quantity: u64
) -> Result<Instruction, ProgramError> {
let accounts = vec![
AccountMeta::new_readonly(*omega_contract_pk, false),
AccountMeta::new_readonly(*user_pk, true),
AccountMeta::new(*user_quote_pk, false),
AccountMeta::new(*vault_pk, false),
AccountMeta::new_readonly(spl_token::id(), false),
AccountMeta::new_readonly(*signer_pk, false),
AccountMeta::new(*winner_mint_pk, false),
AccountMeta::new(*winner_user_pk, false),
];
let instr = OmegaInstruction::RedeemWinner { quantity };
let data = instr.pack();
Ok(Instruction {
program_id: *program_id,
accounts,
data
})
}
pub fn resolve(
program_id: &Pubkey,
omega_contract_pk: &Pubkey,
oracle_pk: &Pubkey,
winner_pk: &Pubkey
) -> Result<Instruction, ProgramError> {
let accounts = vec![
AccountMeta::new(*omega_contract_pk, false),
AccountMeta::new_readonly(*oracle_pk, true),
AccountMeta::new_readonly(*winner_pk, false),
AccountMeta::new_readonly(solana_program::sysvar::clock::ID, false),
];
let instr = OmegaInstruction::Resolve;
let data = instr.pack();
Ok(Instruction {
program_id: *program_id,
accounts,
data
})
}

22
program/src/lib.rs Normal file
View File

@ -0,0 +1,22 @@
#[macro_use]
pub mod error;
pub mod processor;
pub mod state;
pub mod instruction;
use solana_program::{
account_info::AccountInfo, entrypoint::ProgramResult, entrypoint, pubkey::Pubkey,
};
use crate::processor::Processor;
entrypoint!(process_instruction);
fn process_instruction(
program_id: &Pubkey,
accounts: &[AccountInfo],
instruction_data: &[u8],
) -> ProgramResult {
Processor::process(program_id, accounts, instruction_data)
}

427
program/src/processor.rs Normal file
View File

@ -0,0 +1,427 @@
use std::mem::size_of;
use arrayref::{array_ref, array_refs};
use bytemuck::bytes_of;
use solana_program::account_info::AccountInfo;
use solana_program::entrypoint::ProgramResult;
use solana_program::msg;
use solana_program::program::{invoke, invoke_signed};
use solana_program::program_error::ProgramError;
use solana_program::program_pack::Pack;
use solana_program::pubkey::Pubkey;
use solana_program::sysvar::rent::Rent;
use solana_program::sysvar::Sysvar;
use spl_token::state::{Account, Mint};
use crate::error::{OmegaError, OmegaErrorCode, OmegaResult, SourceFileId};
use crate::instruction::OmegaInstruction;
use crate::state::{AccountFlag, DETAILS_BUFFER_LEN, Loadable, MAX_OUTCOMES, OmegaContract};
pub struct Processor {}
declare_check_assert_macros!(SourceFileId::Processor);
impl Processor {
fn init_omega_contract(
program_id: &Pubkey,
accounts: &[AccountInfo],
exp_time: u64,
signer_nonce: u64,
details: &[u8]
) -> OmegaResult<()> {
const NUM_FIXED: usize = 6;
check_assert!(accounts.len() >= NUM_FIXED + 2 && accounts.len() <= NUM_FIXED + MAX_OUTCOMES)?;
let (fixed_accs, outcome_accs) = array_refs![accounts, NUM_FIXED; ..;];
let [
omega_contract_acc,
oracle_acc,
quote_mint_acc,
vault_acc,
signer_acc,
rent_acc
] = fixed_accs;
let rent = Rent::from_account_info(rent_acc)?;
check_assert!(omega_contract_acc.owner == program_id)?;
check_assert!(rent.is_exempt(omega_contract_acc.lamports(), size_of::<OmegaContract>()))?;
check_assert!(details.len() <= DETAILS_BUFFER_LEN)?;
let mut omega_contract = OmegaContract::load_mut(omega_contract_acc)?;
check_assert!(omega_contract.account_flags == 0)?;
let signer_key = gen_signer_key(signer_nonce, omega_contract_acc.key, program_id)?;
check_assert!(signer_key == *signer_acc.key)?;
omega_contract.account_flags = (AccountFlag::Initialized | AccountFlag::OmegaContract).bits();
omega_contract.oracle = *oracle_acc.key;
omega_contract.quote_mint = *quote_mint_acc.key;
omega_contract.exp_time = exp_time;
omega_contract.vault = *vault_acc.key;
omega_contract.signer_key = *signer_acc.key;
omega_contract.signer_nonce = signer_nonce;
omega_contract.winner = Pubkey::default();
omega_contract.num_outcomes = outcome_accs.len();
let details_buf = &mut omega_contract.details[..details.len()];
details_buf.copy_from_slice(details);
let quote_mint = Mint::unpack(&quote_mint_acc.try_borrow_data()?)?;
let vault = Account::unpack(&vault_acc.try_borrow_data()?)?;
check_assert!(vault.owner == signer_key)?;
check_assert!(&vault.mint == quote_mint_acc.key)?;
for (i, outcome_acc) in outcome_accs.iter().enumerate() {
let outcome = Mint::unpack(&outcome_acc.try_borrow_data()?)?;
let authority = outcome.mint_authority.ok_or(OmegaErrorCode::InvalidOutcomeMintAuthority)?;
check_assert!(*outcome_acc.key != Pubkey::default())?;
check_assert!(outcome.is_initialized)?;
check_assert!(authority == signer_key)?;
check_assert!(outcome.supply == 0)?;
check_assert!(outcome.decimals == quote_mint.decimals)?;
omega_contract.outcomes[i] = *outcome_acc.key;
}
Ok(())
}
fn issue_set(
program_id: &Pubkey,
accounts: &[AccountInfo],
quantity: u64
) -> OmegaResult<()> {
const NUM_FIXED: usize = 6;
let (fixed_accs, outcome_accs) = array_refs![accounts, NUM_FIXED; ..;];
let [
omega_contract_acc,
user_acc,
user_quote_acc,
vault_acc,
spl_token_program_acc,
omega_signer_acc,
] = fixed_accs;
// Transfer quote tokens from the user's token wallet
let omega_contract = OmegaContract::load(omega_contract_acc)?;
check_assert!(omega_contract.account_flags == (AccountFlag::Initialized | AccountFlag::OmegaContract).bits())?;
check_assert!(omega_contract_acc.owner == program_id)?;
check_assert!(*vault_acc.key == omega_contract.vault)?;
check_assert!(outcome_accs.len() == 2 * omega_contract.num_outcomes)?;
check_assert!(user_acc.is_signer)?;
let deposit_instruction = spl_token::instruction::transfer(
spl_token_program_acc.key,
user_quote_acc.key,
vault_acc.key,
user_acc.key,
&[],
quantity
)?;
let deposit_accs = [user_quote_acc.clone(), vault_acc.clone(), user_acc.clone(), spl_token_program_acc.clone()];
invoke(&deposit_instruction, &deposit_accs)?;
let signer_seeds = gen_signer_seeds(&omega_contract.signer_nonce, omega_contract_acc.key);
for i in 0..omega_contract.num_outcomes {
let outcome_mint_acc = &outcome_accs[2 * i];
let outcome_user_acc = &outcome_accs[2 * i + 1];
let mint_instruction = spl_token::instruction::mint_to(
spl_token_program_acc.key,
outcome_mint_acc.key,
outcome_user_acc.key,
omega_signer_acc.key,
&[],
quantity,
)?;
let mint_accs = [
outcome_mint_acc.clone(),
outcome_user_acc.clone(),
omega_signer_acc.clone(),
spl_token_program_acc.clone()
];
invoke_signed(&mint_instruction, &mint_accs, &[&signer_seeds])?;
}
Ok(())
}
fn redeem_set(
program_id: &Pubkey,
accounts: &[AccountInfo],
quantity: u64
) -> OmegaResult<()> {
const NUM_FIXED: usize = 6;
let (fixed_accs, outcome_accs) = array_refs![accounts, NUM_FIXED; ..;];
let [
omega_contract_acc,
user_acc,
user_quote_acc,
vault_acc,
spl_token_program_acc,
omega_signer_acc,
] = fixed_accs;
// Transfer outcome tokens for each outcome
let omega_contract = OmegaContract::load(omega_contract_acc)?;
check_assert!(omega_contract.account_flags == (AccountFlag::Initialized | AccountFlag::OmegaContract).bits())?;
check_assert!(omega_contract_acc.owner == program_id)?;
check_assert!(*vault_acc.key == omega_contract.vault)?;
check_assert!(outcome_accs.len() == 2 * omega_contract.num_outcomes)?;
check_assert!(user_acc.is_signer)?;
for i in 0..omega_contract.num_outcomes {
let outcome_mint_acc = &outcome_accs[2 * i];
let outcome_user_acc = &outcome_accs[2 * i + 1];
let burn_instruction = spl_token::instruction::burn(
spl_token_program_acc.key,
outcome_user_acc.key,
outcome_mint_acc.key,
user_acc.key,
&[],
quantity,
)?;
let mint_accs = [
outcome_user_acc.clone(),
outcome_mint_acc.clone(),
user_acc.clone(),
spl_token_program_acc.clone()
];
invoke(&burn_instruction, &mint_accs)?;
}
let withdraw_instruction = spl_token::instruction::transfer(
spl_token_program_acc.key,
vault_acc.key,
user_quote_acc.key,
omega_signer_acc.key,
&[],
quantity
)?;
let withdraw_accs = [
vault_acc.clone(),
user_quote_acc.clone(),
omega_signer_acc.clone(),
spl_token_program_acc.clone()
];
let signer_seeds = gen_signer_seeds(&omega_contract.signer_nonce, omega_contract_acc.key);
invoke_signed(&withdraw_instruction, &withdraw_accs, &[&signer_seeds])?;
Ok(())
}
fn redeem_winner(program_id: &Pubkey, accounts: &[AccountInfo], quantity: u64) -> OmegaResult<()>{
let accounts = array_ref![accounts, 0, 8];
let [
omega_contract_acc,
user_acc,
user_quote_acc,
vault_acc,
spl_token_program_acc,
omega_signer_acc,
winner_mint_acc,
winner_user_acc
] = accounts;
let omega_contract = OmegaContract::load(omega_contract_acc)?;
check_assert!(omega_contract.account_flags == (AccountFlag::Initialized | AccountFlag::OmegaContract).bits())?;
check_assert!(omega_contract_acc.owner == program_id)?;
check_assert!(*vault_acc.key == omega_contract.vault)?;
check_assert!(user_acc.is_signer)?;
check_assert!(omega_contract.winner != Pubkey::default())?;
check_assert!(*winner_mint_acc.key == omega_contract.winner)?;
let burn_instruction = spl_token::instruction::burn(
spl_token_program_acc.key,
winner_user_acc.key,
winner_mint_acc.key,
user_acc.key,
&[],
quantity,
)?;
let mint_accs = [
winner_user_acc.clone(),
winner_mint_acc.clone(),
user_acc.clone(),
spl_token_program_acc.clone()
];
invoke(&burn_instruction, &mint_accs)?;
let withdraw_instruction = spl_token::instruction::transfer(
spl_token_program_acc.key,
vault_acc.key,
user_quote_acc.key,
omega_signer_acc.key,
&[],
quantity
)?;
let withdraw_accs = [
vault_acc.clone(),
user_quote_acc.clone(),
omega_signer_acc.clone(),
spl_token_program_acc.clone()
];
let signer_seeds = gen_signer_seeds(&omega_contract.signer_nonce, omega_contract_acc.key);
invoke_signed(&withdraw_instruction, &withdraw_accs, &[&signer_seeds])?;
Ok(())
}
fn resolve(program_id: &Pubkey, accounts: &[AccountInfo]) -> OmegaResult<()> {
let accounts = array_ref![accounts, 0, 4];
let [
omega_contract_acc,
oracle_acc, // signer
winner_acc,
clock_acc
] = accounts;
let mut omega_contract = OmegaContract::load_mut(omega_contract_acc)?;
check_assert!(omega_contract.account_flags == (AccountFlag::Initialized | AccountFlag::OmegaContract).bits())?;
check_assert!(omega_contract_acc.owner == program_id)?;
check_assert!(omega_contract.oracle == *oracle_acc.key)?;
check_assert!(oracle_acc.is_signer)?;
let clock = solana_program::clock::Clock::from_account_info(clock_acc)?;
let curr_time = clock.unix_timestamp as u64;
check_assert!(omega_contract.exp_time <= curr_time)?;
check_assert!(omega_contract.winner == Pubkey::default())?;
let winner = *winner_acc.key;
for i in 0..omega_contract.num_outcomes {
if winner == omega_contract.outcomes[i] {
omega_contract.winner = winner;
return Ok(());
}
}
Err(OmegaError::ErrorCode(OmegaErrorCode::InvalidWinner))
}
pub fn process(program_id: &Pubkey, accounts: &[AccountInfo], data: &[u8]) -> ProgramResult {
let instruction = OmegaInstruction::unpack(data).ok_or(ProgramError::InvalidInstructionData)?;
match instruction {
OmegaInstruction::InitOmegaContract {
exp_time, signer_nonce,
} => {
msg!("InitOmegaContract");
let details_buffer = &data[16..];
Self::init_omega_contract(program_id, accounts, exp_time, signer_nonce, details_buffer)?;
},
OmegaInstruction::IssueSet {
quantity
} => {
msg!("IssueSet");
Self::issue_set(program_id, accounts, quantity)?;
},
OmegaInstruction::RedeemSet {
quantity
} => {
msg!("RedeemSet");
Self::redeem_set(program_id, accounts, quantity)?;
},
OmegaInstruction::RedeemWinner {
quantity
} => {
msg!("RedeemWinner");
Self::redeem_winner(program_id, accounts, quantity)?;
},
OmegaInstruction::Resolve => {
msg!("Resolve");
Self::resolve(program_id, accounts)?;
}
}
Ok(())
}
}
fn gen_signer_seeds<'a>(nonce: &'a u64, contract_pk: &'a Pubkey) -> [&'a [u8]; 2] {
[contract_pk.as_ref(), bytes_of(nonce)]
}
fn gen_signer_key(
nonce: u64,
contract_pk: &Pubkey,
program_id: &Pubkey,
) -> Result<Pubkey, ProgramError> {
let seeds = gen_signer_seeds(&nonce, contract_pk);
Ok(Pubkey::create_program_address(&seeds, program_id)?)
}
#[cfg(test)]
mod tests {
use std::mem::size_of;
use bytemuck::Pod;
use solana_program::instruction::Instruction;
use solana_program::program_error::PrintProgramError;
use solana_program::rent::Rent;
use solana_sdk::account::{Account, create_account, create_is_signer_account_infos};
use crate::error::OmegaError;
use crate::instruction::*;
use super::*;
fn do_process_instruction(
instruction: Instruction,
accounts: Vec<&mut Account>,
) -> ProgramResult {
let mut meta = instruction
.accounts
.iter()
.zip(accounts)
.map(|(account_meta, account)| (&account_meta.pubkey, account_meta.is_signer, account))
.collect::<Vec<_>>();
let account_infos = create_is_signer_account_infos(&mut meta);
Processor::process(&instruction.program_id, &account_infos, &instruction.data)
}
fn get_rent_exempt<T: Pod>(owner: &Pubkey) -> Account {
let rent = Rent::default();
Account::new(rent.minimum_balance(size_of::<T>()), size_of::<T>(), owner)
}
#[test]
fn test_init_omega_contract() {
let program_id = Pubkey::new_unique();
let omega_contract_pk = Pubkey::new_unique();
let mut omega_contract_acc = get_rent_exempt::<OmegaContract>(&program_id);
let oracle_pk = Pubkey::new_unique();
let mut oracle_acc = Account::default();
let quote_mint_pk = Pubkey::new_unique();
let mut quote_mint_acc = Account::default();
let vault_pk = Pubkey::new_unique();
let mut vault_acc = Account::default();
let signer_pk = Pubkey::new_unique(); // doesn't work
let instruction = init_omega_contract(
&program_id, &omega_contract_pk, &oracle_pk, &quote_mint_pk, &vault_pk, &signer_pk, &[],
0, 0, "DO NOT USE THIS CONTRACT"
).unwrap();
let accounts = vec![&mut omega_contract_acc, &mut oracle_acc, &mut quote_mint_acc, &mut vault_acc];
let result = do_process_instruction(instruction, accounts);
assert!(result == Ok(()));
}
}

52
program/src/state.rs Normal file
View File

@ -0,0 +1,52 @@
use std::cell::{Ref, RefMut};
use solana_program::account_info::AccountInfo;
use solana_program::program_error::ProgramError;
use bytemuck::{from_bytes, from_bytes_mut, Pod, Zeroable};
use solana_program::pubkey::Pubkey;
use enumflags2::BitFlags;
pub const DETAILS_BUFFER_LEN: usize = 2048;
pub const MAX_OUTCOMES: usize = 2;
pub trait Loadable: Pod {
fn load_mut<'a>(account: &'a AccountInfo) -> Result<RefMut<'a, Self>, ProgramError> {
Ok(RefMut::map(account.try_borrow_mut_data()?, |data| from_bytes_mut(data)))
}
fn load<'a>(account: &'a AccountInfo) -> Result<Ref<'a, Self>, ProgramError> {
Ok(Ref::map(account.try_borrow_data()?, |data| from_bytes(data)))
}
fn load_from_bytes(data: &[u8]) -> Result<&Self, ProgramError> {
Ok(from_bytes(data))
}
}
#[derive(Copy, Clone, BitFlags, Debug, Eq, PartialEq)]
#[repr(u64)]
pub enum AccountFlag {
Initialized = 1u64 << 0,
OmegaContract = 1u64 << 1,
}
#[derive(Copy, Clone)]
#[repr(C)]
pub struct OmegaContract {
pub account_flags: u64,
pub oracle: Pubkey, // Right now it's just a single oracle who determines outcome resolution
pub quote_mint: Pubkey, // SPL token of quote currency where winning contract redeems to 1 lot size, e.g. USDC
pub exp_time: u64, // expiration timestamp in seconds since 1970
pub vault: Pubkey, // Where quote currency will be stored
pub signer_key: Pubkey,
pub signer_nonce: u64,
pub winner: Pubkey, // mint address of winning token. Will be 0 if not yet resolved
pub outcomes: [Pubkey; MAX_OUTCOMES],
pub num_outcomes: usize,
pub details: [u8; DETAILS_BUFFER_LEN] // utf-8 encoded string (compressed?) of details about how to resolve contract
}
unsafe impl Zeroable for OmegaContract {}
unsafe impl Pod for OmegaContract {}
impl Loadable for OmegaContract {}

1
ui/.env.production Normal file
View File

@ -0,0 +1 @@
GENERATE_SOURCEMAP = false

25
ui/.gitignore vendored Normal file
View File

@ -0,0 +1,25 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# production
/build
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.idea

21
ui/README.md Normal file
View File

@ -0,0 +1,21 @@
## ⚠️ Warning
Any content produced by Blockworks, or developer resources that Blockworks provides, are for educational and inspiration purposes only. Solana does not encourage, induce or sanction the deployment of any such applications in violation of applicable laws or regulations.
## Deployment
1. Run `yarn build` to create a build of the web frontend locally.
2. Create a backup on the server
```
ssh root@predictomega.org mv /var/www/predictomega.org /var/www/archive/predictomega.org-`date +%s`
```
3. Copy the build to the server
```
scp -r build root@predictomega.org:/var/www/predictomega.org
```

17
ui/craco.config.js Normal file
View File

@ -0,0 +1,17 @@
const CracoLessPlugin = require("craco-less");
module.exports = {
plugins: [
{
plugin: CracoLessPlugin,
options: {
lessLoaderOptions: {
lessOptions: {
modifyVars: { "@primary-color": "#2abdd2" },
javascriptEnabled: true,
},
},
},
},
],
};

BIN
ui/design/logo.psd Normal file

Binary file not shown.

75
ui/package.json Normal file
View File

@ -0,0 +1,75 @@
{
"name": "token-swap-ui",
"version": "0.2.0",
"private": true,
"dependencies": {
"@craco/craco": "^5.7.0",
"@project-serum/serum": "^0.13.11",
"@project-serum/sol-wallet-adapter": "^0.1.1",
"@solana/spl-token": "0.0.11",
"@solana/spl-token-swap": "0.0.2",
"@solana/web3.js": "^0.86.2",
"@testing-library/jest-dom": "^4.2.4",
"@testing-library/react": "^9.5.0",
"@testing-library/user-event": "^7.2.1",
"@types/echarts": "^4.9.0",
"@types/react-router-dom": "^5.1.6",
"antd": "^4.6.6",
"bn.js": "^5.1.3",
"bs58": "^4.0.1",
"buffer-layout": "^1.2.0",
"craco-less": "^1.17.0",
"echarts": "^4.9.0",
"eventemitter3": "^4.0.7",
"identicon.js": "^2.3.3",
"jazzicon": "^1.5.0",
"react": "^16.13.1",
"react-dom": "^16.13.1",
"react-github-btn": "^1.2.0",
"react-router-dom": "^5.2.0",
"react-scripts": "3.4.3",
"typescript": "^4.0.0"
},
"scripts": {
"start": "craco start",
"build": "craco build",
"test": "craco test",
"eject": "react-scripts eject",
"localnet:update": "solana-localnet update",
"localnet:up": "rm client/util/store/config.json; set -x; solana-localnet down; set -e; solana-localnet up",
"localnet:down": "solana-localnet down",
"localnet:logs": "solana-localnet logs -f",
"predeploy": "git pull --ff-only && yarn && yarn build",
"deploy": "gh-pages -d build",
"deploy:ar": "arweave deploy-dir build --key-file ",
"format:fix": "prettier --write \"**/*.+(js|jsx|ts|tsx|json|css|md)\""
},
"eslintConfig": {
"extends": "react-app"
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
},
"homepage": ".",
"devDependencies": {
"@types/bn.js": "^4.11.6",
"@types/bs58": "^4.0.1",
"@types/identicon.js": "^2.3.0",
"@types/jest": "^24.9.1",
"@types/node": "^12.12.62",
"@types/react": "^16.9.50",
"@types/react-dom": "^16.9.8",
"arweave-deploy": "^1.9.1",
"gh-pages": "^3.1.0",
"prettier": "^2.1.2"
}
}

1
ui/public/CNAME Normal file
View File

@ -0,0 +1 @@
swap.projectserum.com

BIN
ui/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

78
ui/public/index.html Normal file
View File

@ -0,0 +1,78 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta name="description" content="Serum token swap on Solana" />
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
-->
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>Omega | Prediction Markets</title>
<style type="text/css">
#root {
height: 100%;
}
#root::before {
content: "";
position: absolute;
top: 0;
left: 0;
min-width: 100%;
min-height: 100%;
filter: grayscale(100%);
background-repeat: no-repeat;
background-size: cover;
}
.App {
position: relative;
height: 100%;
text-align: center;
min-width: 100%;
display: flex;
flex-direction: column;
}
</style>
<link
rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.1/css/all.min.css"
integrity="sha512-+4zCK9k+qNFUR5X+cKL9EIR+ZOhtIloNl9GIKS57V1MyNsYpYcUrUeQc9vNfzsWfV28IaLL3i96P9sdNyeRssA=="
crossorigin="anonymous"
/>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
<script
async
src="https://platform.twitter.com/widgets.js"
charset="utf-8"
></script>
</body>
</html>

BIN
ui/public/logo192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

BIN
ui/public/logo64.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

20
ui/public/manifest.json Normal file
View File

@ -0,0 +1,20 @@
{
"short_name": "Omega",
"name": "Omega | Prediction Markets",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
},
{
"src": "logo192.png",
"type": "image/png",
"sizes": "192x192"
}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

3
ui/public/robots.txt Normal file
View File

@ -0,0 +1,3 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:

221
ui/src/App.less Normal file
View File

@ -0,0 +1,221 @@
@import "~antd/dist/antd.dark.less";
@import "./ant-custom.less";
body {
--row-highlight: @background-color-base;
}
.App-logo {
background-image: url("/logo64.png");
height: 64px;
pointer-events: none;
background-repeat: no-repeat;
background-size: 64px;
width: 64px;
}
.Banner {
min-height: 30px;
width: 100%;
background-color: #fff704;
display: flex;
flex-direction: column;
justify-content: center;
// z-index: 10;
}
.Banner-description {
color: black;
text-align: center;
width: 100%;
}
.App-Bar {
display: grid;
grid-template-columns: 1fr 120px;
-webkit-box-pack: justify;
justify-content: space-between;
-webkit-box-align: center;
align-items: center;
flex-direction: row;
width: 100%;
top: 0px;
position: relative;
padding: 1rem;
z-index: 2;
.ant-menu-horizontal {
border-bottom-color: transparent;
background-color: transparent;
line-height: inherit;
font-size: 16px;
margin: 0 10px;
.ant-menu-item {
margin: 0 10px;
color: lightgrey;
height: 35px;
line-height: 35px;
border-width: 0px !important;
}
.ant-menu-item:hover {
color: white;
border-width: 0px !important;
}
.ant-menu-item-selected {
font-weight: bold;
}
}
}
.App-Bar-left {
box-sizing: border-box;
margin: 0px;
min-width: 0px;
display: flex;
padding: 0px;
-webkit-box-align: center;
align-items: center;
width: fit-content;
}
.App-Bar-right {
display: flex;
flex-direction: row;
-webkit-box-align: center;
align-items: center;
justify-self: flex-end;
}
.ant-tabs-nav-scroll {
display: flex;
justify-content: center;
}
.discord {
font-size: 30px;
color: #7289da;
}
.discord:hover {
color: #8ea1e1;
}
.telegram {
color: #32afed;
font-size: 28px;
background-color: white;
border-radius: 30px;
display: flex;
width: 27px;
height: 27px;
}
.telegram:hover {
color: #2789de !important;
}
.App-header {
background-color: #282c34;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-size: calc(10px + 2vmin);
color: white;
}
.App-link {
color: #61dafb;
}
.social-buttons {
margin-top: auto;
margin-left: auto;
margin-bottom: 0.5rem;
margin-right: 1rem;
gap: 0.3rem;
display: flex;
}
.wallet-wrapper {
background: @background-color-base;
padding-left: 0.7rem;
border-radius: 0.5rem;
display: flex;
align-items: center;
white-space: nowrap;
}
.wallet-key {
background: @background-color-base;
padding: 0.1rem 0.5rem 0.1rem 0.7rem;
margin-left: 0.3rem;
border-radius: 0.5rem;
display: flex;
align-items: center;
}
.exchange-card {
border-radius: 20px;
box-shadow: rgba(0, 0, 0, 0.1) 0px 0px 10px 0px;
width: 450px;
margin: 4px auto;
padding: 0px;
.ant-tabs-tab {
width: 50%;
margin: 0px;
justify-content: center;
border-radius: 20px 20px 0px 0px;
}
.ant-tabs-tab-active {
background-color: @background-color-light;
}
.ant-tabs-nav-list {
width: 100% !important;
}
}
.flash-positive {
color: rgba(0, 255, 0, 0.6);
}
.flash-negative {
color: rgba(255, 0, 0, 0.6);
}
.ant-table-cell {
padding: 6px 16px !important;
}
.ant-table {
margin: 0px 30px;
}
.ant-pagination-options {
display: none;
}
.ant-table-container table > thead > tr th {
text-align: center;
}
.ant-notification {
a {
color: blue;
text-decoration: underline;
cursor: pointer;
}
}
@media only screen and (max-width: 600px) {
.exchange-card {
width: 360px;
}
}

9
ui/src/App.test.tsx Normal file
View File

@ -0,0 +1,9 @@
import React from "react";
import { render } from "@testing-library/react";
import App from "./App";
test("renders learn react link", () => {
const { getByText } = render(<App />);
const linkElement = getByText(/learn react/i);
expect(linkElement).toBeInTheDocument();
});

41
ui/src/App.tsx Normal file
View File

@ -0,0 +1,41 @@
import React from "react";
import "./App.less";
import GitHubButton from "react-github-btn";
import { Routes } from "./routes";
function App() {
return (
<div className="App">
<div className="Banner">
<div className="Banner-description">
Omega is unaudited software. Use at your own risk.
</div>
</div>
<Routes />
{
<div className="social-buttons">
<GitHubButton
href="https://github.com/blockworks-foundation/omega"
data-color-scheme="no-preference: light; light: light; dark: light;"
data-icon="octicon-star"
data-size="large"
data-show-count={true}
aria-label="Star blockworks-foundation/omega on GitHub"
>
Star
</GitHubButton>
<GitHubButton
href="https://github.com/blockworks-foundation/omega/fork"
data-color-scheme="no-preference: light; light: light; dark: light;"
data-size="large"
aria-label="Fork blockworks-foundation/omega on GitHub"
>
Fork
</GitHubButton>
</div>
}
</div>
);
}
export default App;

4
ui/src/ant-custom.less Normal file
View File

@ -0,0 +1,4 @@
@import "~antd/dist/antd.css";
@import "~antd/dist/antd.dark.less";
@primary-color: #ff00a8;
@popover-background: #1a2029;

9
ui/src/buffer-layout.d.ts vendored Normal file
View File

@ -0,0 +1,9 @@
declare module "buffer-layout" {
const magic: any;
export = magic;
}
declare module "jazzicon" {
const magic: any;
export = magic;
}

View File

@ -0,0 +1,71 @@
import React from "react";
import { useWallet } from "./../utils/wallet";
import { shortenAddress } from "./../utils/utils";
import { Identicon } from "./identicon";
import { useUserAccounts, useMint } from "./../utils/accounts";
import contract_keys from "../contract_keys.json";
export const AccountInfo = (props: {}) => {
const { wallet } = useWallet();
const { userAccounts } = useUserAccounts();
const mint = useMint(contract_keys.quote_mint_pk);
if (!wallet || !wallet.publicKey) {
return null;
}
const userUiBalance = () => {
const currentAccount = userAccounts?.find(
(a) => a.info.mint.toBase58() === contract_keys.quote_mint_pk
);
if (currentAccount && mint) {
return (
currentAccount.info.amount.toNumber() / Math.pow(10, mint.decimals)
);
}
return 0;
};
const userYesBalance = () => {
const yesAccount = userAccounts?.find(
(t) => t.info.mint.toBase58() === contract_keys.outcomes[0].mint_pk
);
if (yesAccount && mint) {
return (
yesAccount.info.amount.toNumber() / Math.pow(10, mint.decimals) // outcome mints have same decimals as quote
);
}
return 0;
}
const userNoBalance = () => {
const noAccount = userAccounts?.find(
(t) => t.info.mint.toBase58() === contract_keys.outcomes[1].mint_pk
);
if (noAccount && mint) {
return (
noAccount.info.amount.toNumber() / Math.pow(10, mint.decimals) // outcome mints have same decimals as quote
);
}
return 0;
}
return (
<div className="wallet-wrapper">
<span>
{userUiBalance().toFixed(2)} USDC {userYesBalance().toFixed(2)} YES {userNoBalance().toFixed(2)} NO
</span>
<div className="wallet-key">
{shortenAddress(`${wallet.publicKey}`)}
<Identicon
address={wallet.publicKey.toBase58()}
style={{ marginLeft: "0.5rem" }}
/>
</div>
</div>
);
};

View File

@ -0,0 +1,93 @@
import React from "react";
import { Button, Menu, Popover } from "antd";
import { useWallet } from "../utils/wallet";
import { AccountInfo } from "./accountInfo";
import { Link, useHistory, useLocation } from "react-router-dom";
export const AppBar = (props: { left?: JSX.Element; right?: JSX.Element }) => {
const { connected, wallet } = useWallet();
const location = useLocation();
const history = useHistory();
const TopBar = (
<div className="App-Bar">
<div className="App-Bar-left">
<div className="App-logo" />
<Menu mode="horizontal" selectedKeys={[location.pathname]}>
<Menu.Item key="/">
<Link
to={{
pathname: "/",
}}
>
Predict
</Link>
</Menu.Item>
<Menu.Item key="/exchange">
<Link
to={{
pathname: "/exchange",
}}
>
Exchange
</Link>
</Menu.Item>
<Menu.Item key="/redeem">
<Link
to={{
pathname: "/redeem",
}}
>
Redeem
</Link>
</Menu.Item>
<Menu.Item key="help">
<a
href={"https://www.notion.so/Omega-Help-Center-0e0f30a8976c456aaa59a86e44657754"}
target="_blank"
rel="noopener noreferrer"
>
Help
<sup></sup>
</a>
</Menu.Item>
</Menu>
{props.left}
</div>
<div className="App-Bar-right">
<AccountInfo />
{connected && (
<Button
type="text"
size="large"
onClick={() => history.push({ pathname: "/pool" })}
>
My Pools
</Button>
)}
<div>
{!connected && (
<Button
type="text"
size="large"
onClick={connected ? wallet.disconnect : wallet.connect}
style={{ color: "#2abdd2" }}
>
Connect
</Button>
)}
{connected && (
<Popover
placement="bottomRight"
title="Wallet public key"
trigger="click"
></Popover>
)}
</div>
{props.right}
</div>
</div>
);
return TopBar;
};

85
ui/src/components/bet.tsx Normal file
View File

@ -0,0 +1,85 @@
import React from "react";
import { Button, Card, Popover, Col, Row, Divider } from "antd";
import { Settings } from "./settings";
import { SettingOutlined } from "@ant-design/icons";
import { AppBar } from "./appBar";
import { BetButton } from "./bet/button";
import "./bet/bet.less";
import "./trade/trade.less";
import { CurrencyPairProvider } from "../utils/currencyPair";
import contract_keys from "../contract_keys.json";
export const BetView = (props: {}) => {
return (
<>
<AppBar
right={
<Popover
placement="topRight"
title="Settings"
content={<Settings />}
trigger="click"
>
<Button
shape="circle"
size="large"
type="text"
icon={<SettingOutlined />}
/>
</Popover>
}
/>
<Card
className="bet-card"
headStyle={{ padding: 0 }}
bodyStyle={{ width: 700 }}
>
<div>
<h1>Will the Democrats win the US Senate?</h1>
<Row justify="center">
<Col>
<div className="bet-outcome">
<div>
<h2>YES</h2>
</div>
<div>
<img src={contract_keys.outcomes[0].icon} alt="YES ICON" width="200px" height="200px" />
</div>
<div>
<CurrencyPairProvider baseMintAddress={contract_keys.quote_mint_pk}
quoteMintAddress={contract_keys.outcomes[0].mint_pk} >
<BetButton />
</CurrencyPairProvider>
</div>
</div>
</Col>
<Col>
<div className="bet-outcome">
<h2>NO</h2>
<img src={contract_keys.outcomes[1].icon} alt="NO ICON" width="200px" height="200px"/>
<div>
<CurrencyPairProvider baseMintAddress={contract_keys.quote_mint_pk}
quoteMintAddress={contract_keys.outcomes[1].mint_pk} >
<BetButton />
</CurrencyPairProvider>
</div>
</div>
</Col>
</Row>
<Row justify="center">
<Col>
<p>{contract_keys.details}</p>
<Divider />
<p><b>Disclaimer:</b> Trading on Omega is not available in the United States or other prohibited jurisdictions. If you are located in, incorporated or otherwise established in, or a resident of the United States of America, you are not permitted to trade on Omega.</p>
</Col>
</Row>
</div>
</Card>
</>
);
};

View File

@ -0,0 +1,15 @@
.bet-card {
margin: auto;
}
.bet-outcome {
margin: 1em;
}
.bet-outcome > * {
}
.bet-button {
margin: 1em 0 .5em 0
}

View File

@ -0,0 +1,47 @@
import React, { useEffect } from "react";
import { useHistory } from "react-router-dom";
import { Button } from "antd";
import { PoolOperation } from "../../utils/pools";
import { useCurrencyPairState } from "../../utils/currencyPair";
export const BetButton = (props: {
}) => {
const {
A,
B,
setLastTypedAccount,
setPoolOperation,
} = useCurrencyPairState();
const epsilon = 0.0001;
useEffect( () => {
setPoolOperation(PoolOperation.SwapGivenInput);
setLastTypedAccount(A.mintAddress);
A.setAmount(epsilon.toString());
console.log('A', A.amount, A);
console.log('B', B.amount, B);
}, [A, B, setPoolOperation, setLastTypedAccount]);
const history = useHistory();
let odds = "";
if (B?.amount) {
odds = (100 * epsilon / parseFloat(B.amount)).toFixed(0);
}
return (
<Button
className="bet-button"
type="primary"
size="large"
onClick={() => history.push('/exchange')}
>
<span>{odds}¢</span>
</Button>
);
}

View File

@ -0,0 +1,2 @@
export * from "./input";
export * from "./output";

View File

@ -0,0 +1,50 @@
.bet-input {
margin-top: 10px;
margin-bottom: 10px;
.ant-select-selector,
.ant-select-selector:focus,
.ant-select-selector:active {
border-color: transparent !important;
box-shadow: none !important;
}
;
}
.bet-input-header {
display: grid;
grid-template-columns: repeat(2, 1fr);
grid-column-gap: 10px;
-webkit-box-pack: justify;
justify-content: space-between;
-webkit-box-align: center;
align-items: center;
flex-direction: row;
padding: 10px 20px 0px 20px;
}
.bet-input-header-left {
width: 100%;
box-sizing: border-box;
margin: 0px;
min-width: 0px;
display: flex;
padding: 0px;
-webkit-box-align: center;
align-items: center;
width: fit-content;
}
.bet-input-header-right {
width: 100%;
display: flex;
flex-direction: row;
-webkit-box-align: center;
align-items: center;
justify-self: flex-end;
justify-content: flex-end;
}

View File

@ -0,0 +1,187 @@
import React, { useState } from "react";
import "./input.less";
import { Card, Select } from "antd";
import { PublicKey } from "@solana/web3.js";
import { NumericInput } from "../numericInput";
import { PoolIcon, TokenIcon } from "../tokenIcon";
import { PoolInfo, TokenAccount } from "../../models";
import { getPoolName, getTokenName, isKnownMint } from "../../utils/utils";
import { useUserAccounts, useMint, useCachedPool } from "../../utils/accounts";
import { useConnectionConfig } from "../../utils/connection";
const { Option } = Select;
// this is a slimmed down version of CurrencyInput
export const BetInput = (props: {
mint?: string;
amount?: string;
title?: string;
onInputChange?: (val: string) => void;
onMintChange?: (account: string) => void;
}) => {
const { userAccounts } = useUserAccounts();
const { pools } = useCachedPool();
const mint = useMint(props.mint);
const { tokens, tokenMap } = useConnectionConfig();
const renderPopularTokens = tokens.filter((item) => {
return item.mintAddress === props.mint;
}).map((item) => {
return (
<Option
key={item.mintAddress}
value={item.mintAddress}
name={item.tokenSymbol}
title={item.mintAddress}
>
<div
key={item.mintAddress}
style={{ display: "flex", alignItems: "center" }}
>
<TokenIcon mintAddress={item.mintAddress} />
{item.tokenSymbol}
</div>
</Option>
);
});
// group accounts by mint and use one with biggest balance
const grouppedUserAccounts = userAccounts
.sort((a, b) => {
return b.info.amount.toNumber() - a.info.amount.toNumber();
})
.reduce((map, acc) => {
const mint = acc.info.mint.toBase58();
if (isKnownMint(tokenMap, mint)) {
return map;
}
const pool = pools.find((p) => p && p.pubkeys.mint.toBase58() === mint);
map.set(mint, (map.get(mint) || []).concat([{ account: acc, pool }]));
return map;
}, new Map<string, { account: TokenAccount; pool: PoolInfo | undefined }[]>());
const additionalAccounts = [...grouppedUserAccounts.keys()];
if (
tokens.findIndex((t) => t.mintAddress === props.mint) < 0 &&
props.mint &&
!grouppedUserAccounts.has(props?.mint)
) {
additionalAccounts.push(props.mint);
}
const renderAdditionalTokens = additionalAccounts.map((mint) => {
let pool: PoolInfo | undefined;
const list = grouppedUserAccounts.get(mint);
if (list && list.length > 0) {
// TODO: group multple accounts of same time and select one with max amount
const account = list[0];
pool = account.pool;
}
let name: string;
let icon: JSX.Element;
if (pool) {
name = getPoolName(tokenMap, pool);
const sorted = pool.pubkeys.holdingMints
.map((a: PublicKey) => a.toBase58())
.sort();
icon = <PoolIcon mintA={sorted[0]} mintB={sorted[1]} />;
} else {
name = getTokenName(tokenMap, mint);
icon = <TokenIcon mintAddress={mint} />;
}
return (
<Option key={mint} value={mint} name={name} title={mint}>
<div key={mint} style={{ display: "flex", alignItems: "center" }}>
{icon}
{name}
</div>
</Option>
);
});
const userUiBalance = () => {
const currentAccount = userAccounts?.find(
(a) => a.info.mint.toBase58() === props.mint
);
if (currentAccount && mint) {
return (
currentAccount.info.amount.toNumber() / Math.pow(10, mint.decimals)
);
}
return 0;
};
return (
<Card
className="bet-input"
style={{ borderRadius: 20 }}
bodyStyle={{ padding: 0 }}
>
<div className="bet-input-header">
<div className="bet-input-header-left">{props.title}</div>
<div
className="bet-input-header-right"
onClick={(e) =>
props.onInputChange && props.onInputChange(userUiBalance().toString())
}
>
Balance: {userUiBalance().toFixed(6)}
</div>
</div>
<div className="bet-input-header" style={{ padding: "0px 10px 5px 7px" }}>
<NumericInput
value={props.amount}
onChange={(val: any) => {
if (props.onInputChange) {
props.onInputChange(val.toString());
}
}}
style={{
fontSize: 20,
boxShadow: "none",
borderColor: "transparent",
outline: "transpaernt",
}}
placeholder="0.00"
/>
<div className="bet-input-header-right" style={{ display: "felx" }}>
<Select
placeholder="bet"
size="large"
style={{ minWidth: 120 }}
showSearch
filterOption={(input, option) =>
option?.name?.toLowerCase().indexOf(input.toLowerCase()) >= 0
}
value={props.mint}
onChange={(item) => {
if (props.onMintChange) {
props.onMintChange(item);
}
}}
>
{[...renderPopularTokens, ...renderAdditionalTokens]}
</Select>
</div>
</div>
</Card>
);
};

View File

@ -0,0 +1,37 @@
import React, { useState } from "react";
import { Button, Spin } from "antd";
import { LoadingOutlined } from "@ant-design/icons";
const loadingIcon = <LoadingOutlined style={{ fontSize: 24 }} spin />;
export const BetOutcome = (props: {
outcomeLabel: string;
outcomeImage: string;
className?: string;
}) => {
return (
<>
<div>
<img src={outcomeImage} />
</div>
<div>
<h2>{outcomeLabel}</h2>
</div>
<div>
<Button
className="bet-button"
type="primary"
size="large"
onClick={connected ? handleSwap : wallet.connect}
>
<span>13¢</span>
{pendingTx && <Spin indicator={loadingIcon} className="trade-spinner" />}
</Button>
</div>
</>
);
};

View File

@ -0,0 +1,182 @@
import React, { useState } from "react";
import "./input.less";
import { Card, Select } from "antd";
import { PublicKey } from "@solana/web3.js";
import { NumericInput } from "../numericInput";
import { PoolIcon, TokenIcon } from "../tokenIcon";
import { PoolInfo, TokenAccount } from "../../models";
import { getPoolName, getTokenName, isKnownMint } from "../../utils/utils";
import { useUserAccounts, useMint, useCachedPool } from "../../utils/accounts";
import { useConnectionConfig } from "../../utils/connection";
const { Option } = Select;
// this is a slimmed down version of CurrencyInput
export const BetOutput = (props: {
mint?: string;
amount?: string;
title?: string;
onInputChange?: (val: string) => void;
onMintChange?: (account: string) => void;
}) => {
const { userAccounts } = useUserAccounts();
const { pools } = useCachedPool();
const mint = useMint(props.mint);
const { tokens, tokenMap } = useConnectionConfig();
const renderPopularTokens = tokens.filter((item) => {
return item.mintAddress === props.mint;
}).map((item) => {
return (
<Option
key={item.mintAddress}
value={item.mintAddress}
name={item.tokenSymbol}
title={item.mintAddress}
>
<div
key={item.mintAddress}
style={{ display: "flex", alignItems: "center" }}
>
<TokenIcon mintAddress={item.mintAddress} />
{item.tokenSymbol}
</div>
</Option>
);
});
// group accounts by mint and use one with biggest balance
const grouppedUserAccounts = userAccounts
.sort((a, b) => {
return b.info.amount.toNumber() - a.info.amount.toNumber();
})
.reduce((map, acc) => {
const mint = acc.info.mint.toBase58();
if (isKnownMint(tokenMap, mint)) {
return map;
}
const pool = pools.find((p) => p && p.pubkeys.mint.toBase58() === mint);
map.set(mint, (map.get(mint) || []).concat([{ account: acc, pool }]));
return map;
}, new Map<string, { account: TokenAccount; pool: PoolInfo | undefined }[]>());
const additionalAccounts = [...grouppedUserAccounts.keys()];
if (
tokens.findIndex((t) => t.mintAddress === props.mint) < 0 &&
props.mint &&
!grouppedUserAccounts.has(props?.mint)
) {
additionalAccounts.push(props.mint);
}
const renderAdditionalTokens = additionalAccounts.map((mint) => {
let pool: PoolInfo | undefined;
const list = grouppedUserAccounts.get(mint);
if (list && list.length > 0) {
// TODO: group multple accounts of same time and select one with max amount
const account = list[0];
pool = account.pool;
}
let name: string;
let icon: JSX.Element;
if (pool) {
name = getPoolName(tokenMap, pool);
const sorted = pool.pubkeys.holdingMints
.map((a: PublicKey) => a.toBase58())
.sort();
icon = <PoolIcon mintA={sorted[0]} mintB={sorted[1]} />;
} else {
name = getTokenName(tokenMap, mint);
icon = <TokenIcon mintAddress={mint} />;
}
return (
<Option key={mint} value={mint} name={name} title={mint}>
<div key={mint} style={{ display: "flex", alignItems: "center" }}>
{icon}
{name}
</div>
</Option>
);
});
const userUiBalance = () => {
const currentAccount = userAccounts?.find(
(a) => a.info.mint.toBase58() === props.mint
);
if (currentAccount && mint) {
return (
currentAccount.info.amount.toNumber() / Math.pow(10, mint.decimals)
);
}
return 0;
};
return (
<Card
className="bet-input"
style={{ borderRadius: 20 }}
bodyStyle={{ padding: 0 }}
>
<div className="bet-input-header">
<div className="bet-input-header-left">{props.title}</div>
<div
className="bet-input-header-right"
onClick={(e) =>
props.onInputChange && props.onInputChange(userUiBalance().toString())
}
>
Balance: {userUiBalance().toFixed(6)}
</div>
</div>
<div className="bet-input-header" style={{ padding: "0px 10px 5px 7px" }}>
<NumericInput
value={props.amount}
onChange={(val: any) => {
if (props.onInputChange) {
props.onInputChange(val.toString());
}
}}
style={{
fontSize: 20,
boxShadow: "none",
borderColor: "transparent",
outline: "transpaernt",
}}
placeholder="0.00"
/>
<div className="bet-input-header-right" style={{ display: "felx" }}>
<Select
placeholder="bet"
size="large"
style={{ minWidth: 120 }}
value={props.mint}
disabled
>
{[...renderPopularTokens, ...renderAdditionalTokens]}
</Select>
</div>
</div>
</Card>
);
};

View File

@ -0,0 +1,357 @@
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { Button, Popover, Table, Tooltip, Typography } from "antd";
import { AppBar } from "./../appBar";
import { Settings } from "../settings";
import {
SettingOutlined,
TableOutlined,
OneToOneOutlined,
} from "@ant-design/icons";
import { PoolIcon } from "../tokenIcon";
import { Input } from "antd";
import "./styles.less";
import echarts from "echarts";
import { useEnrichedPools } from "../../context/market";
import { usePools } from "../../utils/pools";
import {
formatNumber,
formatPct,
formatUSD,
useLocalStorageState,
} from "../../utils/utils";
import { PoolAddress } from "../pool/address";
import { PoolCard } from "./../pool/card";
const { Text } = Typography;
const { Search } = Input;
const FlashText = (props: { text: string; val: number }) => {
const [activeClass, setActiveClass] = useState("");
const [value] = useState(props.val);
useEffect(() => {
if (props.val !== value) {
setActiveClass(props.val > value ? "flash-positive" : "flash-negative");
setTimeout(() => setActiveClass(""), 200);
}
}, [props.text, props.val, value]);
return <span className={activeClass}>{props.text}</span>;
};
interface Totals {
liquidity: number;
volume: number;
fees: number;
}
const DEFAULT_DISPLAY_TYPE = "Table";
export const ChartsView = React.memo(() => {
const [search, setSearch] = useState<string>("");
const [totals, setTotals] = useState<Totals>(() => ({
liquidity: 0,
volume: 0,
fees: 0,
}));
const chartDiv = useRef<HTMLDivElement>(null);
const echartsRef = useRef<any>(null);
const { pools } = usePools();
const enriched = useEnrichedPools(pools);
const [infoDisplayType, setInfoDisplayType] = useLocalStorageState(
"infoDisplayType",
DEFAULT_DISPLAY_TYPE
);
useEffect(() => {
if (chartDiv.current) {
echartsRef.current = echarts.init(chartDiv.current);
}
return () => {
echartsRef.current.dispose();
};
}, []);
// TODO: display user percent in the pool
// const { ownedPools } = useOwnedPools();
// TODO: create cache object with layout type, get, query, add
let searchRegex: RegExp | undefined = useMemo(() => {
try {
return new RegExp(search, "i");
} catch {
// ignore bad regex typed by user
}
}, [search]);
const updateChart = useCallback(() => {
if (echartsRef.current) {
echartsRef.current.setOption({
series: [
{
name: "Liquidity",
type: "treemap",
top: 0,
bottom: 10,
left: 30,
right: 30,
animation: false,
// visibleMin: 300,
label: {
show: true,
formatter: "{b}",
},
itemStyle: {
normal: {
borderColor: "#000",
},
},
breadcrumb: {
show: false,
},
data: enriched
.filter(
(row) => !search || !searchRegex || searchRegex.test(row.name)
)
.map((row) => {
return {
value: row.liquidity,
name: row.name,
path: `Liquidity/${row.name}`,
data: row,
};
}),
},
],
});
}
}, [enriched, search, searchRegex]);
// Updates total values
useEffect(() => {
setTotals(
enriched.reduce(
(acc, item) => {
acc.liquidity = acc.liquidity + item.liquidity;
acc.volume = acc.volume + item.volume24h;
acc.fees = acc.fees + item.fees;
return acc;
},
{ liquidity: 0, volume: 0, fees: 0 } as Totals
)
);
updateChart();
}, [enriched, updateChart, search]);
const columns = [
{
title: "Name",
dataIndex: "name",
key: "name",
render(text: string, record: any) {
return {
props: {
style: {},
},
children: (
<div style={{ display: "flex" }}>
<PoolIcon mintA={record.mints[0]} mintB={record.mints[1]} />
<a href={record.link} target="_blank" rel="noopener noreferrer">
{text}
</a>
</div>
),
};
},
},
{
title: "Liquidity",
dataIndex: "liquidity",
key: "liquidity",
render(text: string, record: any) {
return {
props: {
style: { textAlign: "right" },
},
children: (
<div>
<div>{formatUSD.format(record.liquidity)}</div>
<div>
<Text type="secondary" style={{ fontSize: 11 }}>
{formatNumber.format(record.liquidityA)} {record.names[0]}
</Text>
</div>
<div>
<Text type="secondary" style={{ fontSize: 11 }}>
{formatNumber.format(record.liquidityB)} {record.names[1]}
</Text>
</div>
</div>
),
};
},
sorter: (a: any, b: any) => a.liquidity - b.liquidity,
defaultSortOrder: "descend" as any,
},
{
title: "Supply",
dataIndex: "supply",
key: "supply",
render(text: string, record: any) {
return {
props: {
style: { textAlign: "right" },
},
children: <FlashText text={text} val={record.supply} />,
};
},
sorter: (a: any, b: any) => a.supply - b.supply,
},
{
title: "Volume (24h)",
dataIndex: "volume",
key: "volume",
render(text: string, record: any) {
return {
props: {
style: { textAlign: "right" },
},
children: (
<FlashText
text={formatUSD.format(record.volume24h)}
val={record.volume24h}
/>
),
};
},
sorter: (a: any, b: any) => a.volume24h - b.volume24h,
},
{
title: "Fees (24h)",
dataIndex: "fees24h",
key: "fees24h",
render(text: string, record: any) {
return {
props: {
style: { textAlign: "right" },
},
children: (
<FlashText
text={formatUSD.format(record.fees24h)}
val={record.fees24h}
/>
),
};
},
sorter: (a: any, b: any) => a.fees24h - b.fees24h,
},
{
title: "APY",
dataIndex: "apy",
key: "apy",
render(text: string, record: any) {
return {
props: {
style: { textAlign: "right" },
},
children: formatPct.format(record.apy),
};
},
sorter: (a: any, b: any) => a.apy - b.apy,
},
{
title: "Address",
dataIndex: "address",
key: "address",
render(text: string, record: any) {
return {
props: {
style: { fontFamily: "monospace" } as React.CSSProperties,
},
children: <PoolAddress pool={record.raw} />,
};
},
},
];
return (
<>
<AppBar
right={
<Popover
placement="topRight"
title="Settings"
content={<Settings />}
trigger="click"
>
<Button
shape="circle"
size="large"
type="text"
icon={<SettingOutlined />}
/>
</Popover>
}
/>
<div className="info-header">
<h1>Liquidity: {formatUSD.format(totals.liquidity)}</h1>
<h1>Volume (24h): {formatUSD.format(totals.volume)}</h1>
<Search
className="search-input"
placeholder="Filter"
type="search"
value={search}
onChange={(e) => setSearch(e.target.value)}
onSearch={(value) => setSearch(value)}
style={{ width: 200 }}
/>
<Tooltip title="Show as table">
<Button
size="small"
type={infoDisplayType === "Table" ? "primary" : "text"}
onClick={() => setInfoDisplayType("Table")}
icon={<TableOutlined />}
/>
</Tooltip>
<Tooltip title="Show as cards">
<Button
size="small"
type={infoDisplayType === "Card" ? "primary" : "text"}
onClick={() => setInfoDisplayType("Card")}
icon={<OneToOneOutlined />}
/>
</Tooltip>
</div>
<div ref={chartDiv} style={{ height: "250px", width: "100%" }} />
{infoDisplayType === "Table" ? (
<Table
dataSource={enriched.filter(
(row) => !search || !searchRegex || searchRegex.test(row.name)
)}
columns={columns}
size="small"
pagination={{ pageSize: 10 }}
/>
) : (
<div className="pool-grid">
{enriched
.sort((a, b) => b.liquidity - a.liquidity)
.map((p) => {
return <PoolCard pool={p.raw} />;
})}
</div>
)}
</>
);
});

View File

@ -0,0 +1,14 @@
.info-header {
display: flex;
gap: 1em;
align-self: center;
width: 100%;
padding: 0px 30px;
align-items: center;
.search-input {
height: 30px;
margin-left: auto;
margin-right: 0px;
}
}

View File

@ -0,0 +1,234 @@
import React from "react";
import { Card, Select } from "antd";
import { NumericInput } from "../numericInput";
import { getPoolName, getTokenName, isKnownMint } from "../../utils/utils";
import {
useUserAccounts,
useMint,
useCachedPool,
useAccountByMint,
} from "../../utils/accounts";
import "./styles.less";
import { useConnectionConfig } from "../../utils/connection";
import { PoolIcon, TokenIcon } from "../tokenIcon";
import { PublicKey } from "@solana/web3.js";
import { PoolInfo, TokenAccount } from "../../models";
const { Option } = Select;
const TokenDisplay = (props: {
name: string;
mintAddress: string;
icon?: JSX.Element;
showBalance?: boolean;
}) => {
const { showBalance, mintAddress, name, icon } = props;
const tokenMint = useMint(mintAddress);
const tokenAccount = useAccountByMint(mintAddress);
let balance: number = 0;
let hasBalance: boolean = false;
if (showBalance) {
if (tokenAccount && tokenMint) {
balance =
tokenAccount.info.amount.toNumber() / Math.pow(10, tokenMint.decimals);
hasBalance = balance > 0;
}
}
return (
<>
<div
title={mintAddress}
key={mintAddress}
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}}
>
<div style={{ display: "flex", alignItems: "center" }}>
{icon || <TokenIcon mintAddress={mintAddress} />}
{name}
</div>
{showBalance ? (
<span
title={balance.toString()}
key={mintAddress}
className="token-balance"
>
&nbsp;{" "}
{hasBalance && balance < 0.001 ? "<0.001" : balance.toFixed(3)}
</span>
) : null}
</div>
</>
);
};
export const CurrencyInput = (props: {
mint?: string;
amount?: string;
title?: string;
onInputChange?: (val: number) => void;
onMintChange?: (account: string) => void;
}) => {
const { userAccounts } = useUserAccounts();
const { pools } = useCachedPool();
const mint = useMint(props.mint);
const { tokens, tokenMap } = useConnectionConfig();
const renderPopularTokens = tokens.map((item) => {
return (
<Option
key={item.mintAddress}
value={item.mintAddress}
name={item.tokenSymbol}
title={item.mintAddress}
>
<TokenDisplay
key={item.mintAddress}
name={item.tokenSymbol}
mintAddress={item.mintAddress}
showBalance={true}
/>
</Option>
);
});
// TODO: expand nested pool names ...?
// group accounts by mint and use one with biggest balance
const grouppedUserAccounts = userAccounts
.sort((a, b) => {
return b.info.amount.toNumber() - a.info.amount.toNumber();
})
.reduce((map, acc) => {
const mint = acc.info.mint.toBase58();
if (isKnownMint(tokenMap, mint)) {
return map;
}
const pool = pools.find((p) => p && p.pubkeys.mint.toBase58() === mint);
map.set(mint, (map.get(mint) || []).concat([{ account: acc, pool }]));
return map;
}, new Map<string, { account: TokenAccount; pool: PoolInfo | undefined }[]>());
const additionalAccounts = [...grouppedUserAccounts.keys()];
if (
tokens.findIndex((t) => t.mintAddress === props.mint) < 0 &&
props.mint &&
!grouppedUserAccounts.has(props?.mint)
) {
additionalAccounts.push(props.mint);
}
const renderAdditionalTokens = additionalAccounts.map((mint) => {
let pool: PoolInfo | undefined;
const list = grouppedUserAccounts.get(mint);
if (list && list.length > 0) {
// TODO: group multple accounts of same time and select one with max amount
const account = list[0];
pool = account.pool;
}
let name: string;
let icon: JSX.Element;
if (pool) {
name = getPoolName(tokenMap, pool);
const sorted = pool.pubkeys.holdingMints
.map((a: PublicKey) => a.toBase58())
.sort();
icon = <PoolIcon mintA={sorted[0]} mintB={sorted[1]} />;
} else {
name = getTokenName(tokenMap, mint, true, 3);
icon = <TokenIcon mintAddress={mint} />;
}
return (
<Option key={mint} value={mint} name={name}>
<TokenDisplay
key={mint}
mintAddress={mint}
name={name}
icon={icon}
showBalance={!pool}
/>
</Option>
);
});
const userUiBalance = () => {
const currentAccount = userAccounts?.find(
(a) => a.info.mint.toBase58() === props.mint
);
if (currentAccount && mint) {
return (
currentAccount.info.amount.toNumber() / Math.pow(10, mint.decimals)
);
}
return 0;
};
return (
<Card
className="ccy-input"
style={{ borderRadius: 20 }}
bodyStyle={{ padding: 0 }}
>
<div className="ccy-input-header">
<div className="ccy-input-header-left">{props.title}</div>
<div
className="ccy-input-header-right"
onClick={(e) =>
props.onInputChange && props.onInputChange(userUiBalance())
}
>
Balance: {userUiBalance().toFixed(6)}
</div>
</div>
<div className="ccy-input-header" style={{ padding: "0px 10px 5px 7px" }}>
<NumericInput
value={props.amount}
onChange={(val: any) => {
if (props.onInputChange) {
props.onInputChange(val);
}
}}
style={{
fontSize: 20,
boxShadow: "none",
borderColor: "transparent",
outline: "transpaernt",
}}
placeholder="0.00"
/>
<div className="ccy-input-header-right" style={{ display: "felx" }}>
<Select
size="large"
showSearch
style={{ minWidth: 150 }}
placeholder="CCY"
value={props.mint}
onChange={(item) => {
if (props.onMintChange) {
props.onMintChange(item);
}
}}
filterOption={(input, option) =>
option?.name?.toLowerCase().indexOf(input.toLowerCase()) >= 0
}
>
{[...renderPopularTokens, ...renderAdditionalTokens]}
</Select>
</div>
</div>
</Card>
);
};

View File

@ -0,0 +1,62 @@
.ccy-input {
margin-top: 10px;
margin-bottom: 10px;
.ant-select-selector,
.ant-select-selector:focus,
.ant-select-selector:active {
border-color: transparent !important;
box-shadow: none !important;
};
.ant-select-selection-item {
display: flex;
.token-balance {
display: none;
};
};
}
.token-balance {
color: grey;
}
.ccy-input-header {
display: grid;
grid-template-columns: repeat(2, 1fr);
grid-column-gap: 10px;
-webkit-box-pack: justify;
justify-content: space-between;
-webkit-box-align: center;
align-items: center;
flex-direction: row;
padding: 10px 20px 0px 20px;
}
.ccy-input-header-left {
width: 100%;
box-sizing: border-box;
margin: 0px;
min-width: 0px;
display: flex;
padding: 0px;
-webkit-box-align: center;
align-items: center;
width: fit-content;
}
.ccy-input-header-right {
width: 100%;
display: flex;
flex-direction: row;
-webkit-box-align: center;
align-items: center;
justify-self: flex-end;
justify-content: flex-end;
}
.ant-select-dropdown {
width: 150px !important;
};

View File

@ -0,0 +1,54 @@
import React from "react";
import { Button, Popover, Col, Row } from "antd";
import { Settings } from "./settings";
import { SettingOutlined } from "@ant-design/icons";
import { AppBar } from "./appBar";
import { CurrencyPairProvider } from "../utils/currencyPair";
import { SwapView } from "./swap";
import contract_keys from "../contract_keys.json";
export const ExchangeView = (props: {}) => {
const colStyle: React.CSSProperties = { padding: "1em" };
return (
<>
<AppBar
right={
<Popover
placement="topRight"
title="Settings"
content={<Settings />}
trigger="click"
>
<Button
shape="circle"
size="large"
type="text"
icon={<SettingOutlined />}
/>
</Popover>
}
/>
<Row justify="center">
<Col>
<div style={colStyle}>
<CurrencyPairProvider baseMintAddress={contract_keys.quote_mint_pk}
quoteMintAddress={contract_keys.outcomes[0].mint_pk} >
<SwapView />
</CurrencyPairProvider>
</div>
</Col>
<Col>
<div style={colStyle}>
<CurrencyPairProvider baseMintAddress={contract_keys.quote_mint_pk}
quoteMintAddress={contract_keys.outcomes[1].mint_pk} >
<SwapView />
</CurrencyPairProvider>
</div>
</Col>
</Row>
</>
);
};

View File

@ -0,0 +1,43 @@
import React from "react";
import { Typography } from "antd";
import { shortenAddress } from "./../utils/utils";
import { PublicKey } from "@solana/web3.js";
export const ExplorerLink = (props: {
address: string | PublicKey;
type: string;
code?: boolean;
style?: React.CSSProperties;
length?: number;
}) => {
const { type, code } = props;
const address =
typeof props.address === "string"
? props.address
: props.address?.toBase58();
if (!address) {
return null;
}
const length = props.length ?? 9;
return (
<a
href={`https://explorer.solana.com/${type}/${address}`}
// eslint-disable-next-line react/jsx-no-target-blank
target="_blank"
title={address}
style={props.style}
>
{code ? (
<Typography.Text style={props.style} code>
{shortenAddress(address, length)}
</Typography.Text>
) : (
shortenAddress(address, length)
)}
</a>
);
};

View File

@ -0,0 +1,20 @@
import { Button, Popover } from "antd";
import React from "react";
import { InfoCircleOutlined } from "@ant-design/icons";
export const Info = (props: {
text: React.ReactElement;
style?: React.CSSProperties;
}) => {
return (
<Popover
trigger="hover"
content={<div style={{ width: 300 }}>{props.text}</div>}
>
<Button type="text" shape="circle">
<InfoCircleOutlined style={props.style} />
</Button>
</Popover>
);
};

View File

@ -0,0 +1,31 @@
import React, { useEffect, useRef } from "react";
import Jazzicon from "jazzicon";
import bs58 from "bs58";
import "./style.less";
export const Identicon = (props: {
address?: string;
style?: React.CSSProperties;
className?: string;
}) => {
const { address, style } = props;
const ref = useRef<HTMLDivElement>();
useEffect(() => {
if (address && ref.current) {
ref.current.innerHTML = "";
ref.current.className = props.className || "";
ref.current.appendChild(
Jazzicon(
style?.width || 16,
parseInt(bs58.decode(address).toString("hex").slice(5, 15), 16)
)
);
}
}, [address, style, props.className]);
return (
<div className="identicon-wrapper" ref={ref as any} style={props.style} />
);
};

View File

@ -0,0 +1,8 @@
.identicon-wrapper {
display: flex;
height: 1rem;
width: 1rem;
border-radius: 1.125rem;
margin: 0.2rem 0.2rem 0.2rem 0.1rem;
/* background-color: ${({ theme }) => theme.bg4}; */
}

View File

@ -0,0 +1,38 @@
import { CurrencyContextState } from "../utils/currencyPair";
import { getTokenName, KnownTokenMap } from "../utils/utils";
export const CREATE_POOL_LABEL = "Create Liquidity Pool";
export const INSUFFICIENT_FUNDS_LABEL = (tokenName: string) =>
`Insufficient ${tokenName} funds`;
export const POOL_NOT_AVAILABLE = (tokenA: string, tokenB: string) =>
`Pool ${tokenA}/${tokenB} doesn't exsist`;
export const ADD_LIQUIDITY_LABEL = "Provide Liquidity";
export const SWAP_LABEL = "Swap";
export const CONNECT_LABEL = "Connect Wallet";
export const SELECT_TOKEN_LABEL = "Select a token";
export const ENTER_AMOUNT_LABEL = "Enter an amount";
export const generateActionLabel = (
action: string,
connected: boolean,
tokenMap: KnownTokenMap,
A: CurrencyContextState,
B: CurrencyContextState,
ignoreToBalance: boolean = false
) => {
return !connected
? CONNECT_LABEL
: !A.mintAddress
? SELECT_TOKEN_LABEL
: !A.amount
? ENTER_AMOUNT_LABEL
: !B.mintAddress
? SELECT_TOKEN_LABEL
: !B.amount
? ENTER_AMOUNT_LABEL
: !A.sufficientBalance()
? INSUFFICIENT_FUNDS_LABEL(getTokenName(tokenMap, A.mintAddress))
: ignoreToBalance || B.sufficientBalance()
? action
: INSUFFICIENT_FUNDS_LABEL(getTokenName(tokenMap, B.mintAddress));
};

View File

@ -0,0 +1,39 @@
import React from "react";
import { Input } from "antd";
export class NumericInput extends React.Component<any, any> {
onChange = (e: any) => {
const { value } = e.target;
const reg = /^-?\d*(\.\d*)?$/;
if (reg.test(value) || value === "" || value === "-") {
this.props.onChange(value);
}
};
// '.' at the end or only '-' in the input box.
onBlur = () => {
const { value, onBlur, onChange } = this.props;
let valueTemp = value;
if (value.charAt(value.length - 1) === "." || value === "-") {
valueTemp = value.slice(0, -1);
}
if (value.startsWith(".") || value.startsWith("-.")) {
valueTemp = valueTemp.replace(".", "0.");
}
onChange(valueTemp.replace(/0*(\d+)/, "$1"));
if (onBlur) {
onBlur();
}
};
render() {
return (
<Input
{...this.props}
onChange={this.onChange}
onBlur={this.onBlur}
maxLength={25}
/>
);
}
}

View File

@ -0,0 +1,33 @@
.pool-settings-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.5em 1em;
align-items: center;
text-align: right;
input {
text-align: right;
}
}
.add-button {
width: 100%;
position: relative;
:first-child {
width: 100%;
position: relative;
}
}
.add-spinner {
position: absolute;
right: 5px;
}
.input-card {
display: flex;
flex-direction: column;
align-items: center;
margin-bottom: 10px;
}

View File

@ -0,0 +1,326 @@
import React, { useMemo, useState } from "react";
import {
addLiquidity,
usePoolForBasket,
PoolOperation,
} from "../../utils/pools";
import { Button, Card, Col, Dropdown, Popover, Row } from "antd";
import { useWallet } from "../../utils/wallet";
import {
useConnection,
useConnectionConfig,
useSlippageConfig,
} from "../../utils/connection";
import { Spin } from "antd";
import { LoadingOutlined } from "@ant-design/icons";
import { notify } from "../../utils/notifications";
import { SupplyOverview } from "./supplyOverview";
import { CurrencyInput } from "../currencyInput";
import { DEFAULT_DENOMINATOR, PoolConfigCard } from "./config";
import "./add.less";
import { PoolConfig, PoolInfo } from "../../models";
import { SWAP_PROGRAM_OWNER_FEE_ADDRESS } from "../../utils/ids";
import { useCurrencyPairState } from "../../utils/currencyPair";
import {
CREATE_POOL_LABEL,
ADD_LIQUIDITY_LABEL,
generateActionLabel,
} from "../labels";
import { AdressesPopover } from "./address";
import { formatPriceNumber } from "../../utils/utils";
import { useMint, useUserAccounts } from "../../utils/accounts";
import { useEnrichedPools } from "../../context/market";
import { PoolIcon } from "../tokenIcon";
const antIcon = <LoadingOutlined style={{ fontSize: 24 }} spin />;
export const AddToLiquidity = () => {
const { wallet, connected } = useWallet();
const connection = useConnection();
const [pendingTx, setPendingTx] = useState(false);
const {
A,
B,
setLastTypedAccount,
setPoolOperation,
} = useCurrencyPairState();
const pool = usePoolForBasket([A?.mintAddress, B?.mintAddress]);
const { slippage } = useSlippageConfig();
const { tokenMap } = useConnectionConfig();
const [options, setOptions] = useState<PoolConfig>({
curveType: 0,
tradeFeeNumerator: 25,
tradeFeeDenominator: DEFAULT_DENOMINATOR,
ownerTradeFeeNumerator: 5,
ownerTradeFeeDenominator: DEFAULT_DENOMINATOR,
ownerWithdrawFeeNumerator: 0,
ownerWithdrawFeeDenominator: DEFAULT_DENOMINATOR,
});
const executeAction = !connected
? wallet.connect
: async () => {
if (A.account && B.account && A.mint && B.mint) {
setPendingTx(true);
const components = [
{
account: A.account,
mintAddress: A.mintAddress,
amount: A.convertAmount(),
},
{
account: B.account,
mintAddress: B.mintAddress,
amount: B.convertAmount(),
},
];
addLiquidity(connection, wallet, components, slippage, pool, options)
.then(() => {
setPendingTx(false);
})
.catch((e) => {
console.log("Transaction failed", e);
notify({
description:
"Please try again and approve transactions from your wallet",
message: "Adding liquidity cancelled.",
type: "error",
});
setPendingTx(false);
});
}
};
const hasSufficientBalance = A.sufficientBalance() && B.sufficientBalance();
const createPoolButton = SWAP_PROGRAM_OWNER_FEE_ADDRESS ? (
<Button
className="add-button"
onClick={executeAction}
disabled={
connected &&
(pendingTx || !A.account || !B.account || A.account === B.account)
}
type="primary"
size="large"
>
{generateActionLabel(CREATE_POOL_LABEL, connected, tokenMap, A, B)}
{pendingTx && <Spin indicator={antIcon} className="add-spinner" />}
</Button>
) : (
<Dropdown.Button
className="add-button"
onClick={executeAction}
disabled={
connected &&
(pendingTx || !A.account || !B.account || A.account === B.account)
}
type="primary"
size="large"
overlay={<PoolConfigCard options={options} setOptions={setOptions} />}
>
{generateActionLabel(CREATE_POOL_LABEL, connected, tokenMap, A, B)}
{pendingTx && <Spin indicator={antIcon} className="add-spinner" />}
</Dropdown.Button>
);
return (
<>
<div className="input-card">
<AdressesPopover pool={pool} aName={A.name} bName={B.name} />
<Popover
trigger="hover"
content={
<div style={{ width: 300 }}>
Liquidity providers earn a fixed percentage fee on all trades
proportional to their share of the pool. Fees are added to the
pool, accrue in real time and can be claimed by withdrawing your
liquidity.
</div>
}
>
<Button type="text">Read more about providing liquidity.</Button>
</Popover>
<CurrencyInput
title="Input"
onInputChange={(val: any) => {
setPoolOperation(PoolOperation.Add);
if (A.amount !== val) {
setLastTypedAccount(A.mintAddress);
}
A.setAmount(val);
}}
amount={A.amount}
mint={A.mintAddress}
onMintChange={(item) => {
A.setMint(item);
}}
/>
<div>+</div>
<CurrencyInput
title="Input"
onInputChange={(val: any) => {
setPoolOperation(PoolOperation.Add);
if (B.amount !== val) {
setLastTypedAccount(B.mintAddress);
}
B.setAmount(val);
}}
amount={B.amount}
mint={B.mintAddress}
onMintChange={(item) => {
B.setMint(item);
}}
/>
{pool && <PoolPrice pool={pool} />}
<SupplyOverview pool={pool} />
</div>
{pool && (
<Button
className="add-button"
type="primary"
size="large"
onClick={executeAction}
disabled={
connected &&
(pendingTx ||
!A.account ||
!B.account ||
A.account === B.account ||
!hasSufficientBalance)
}
>
{generateActionLabel(ADD_LIQUIDITY_LABEL, connected, tokenMap, A, B)}
{pendingTx && <Spin indicator={antIcon} className="add-spinner" />}
</Button>
)}
{!pool && createPoolButton}
<YourPosition pool={pool} />
</>
);
};
export const PoolPrice = (props: { pool: PoolInfo }) => {
const pool = props.pool;
const pools = useMemo(() => [props.pool].filter((p) => p) as PoolInfo[], [
props.pool,
]);
const enriched = useEnrichedPools(pools)[0];
const { userAccounts } = useUserAccounts();
const lpMint = useMint(pool.pubkeys.mint);
const ratio =
userAccounts
.filter((f) => pool.pubkeys.mint.equals(f.info.mint))
.reduce((acc, item) => item.info.amount.toNumber() + acc, 0) /
(lpMint?.supply.toNumber() || 0);
if (!enriched) {
return null;
}
return (
<Card
className="ccy-input"
style={{ borderRadius: 20, width: "100%" }}
bodyStyle={{ padding: "7px" }}
size="small"
title="Prices and pool share"
>
<Row style={{ width: "100%" }}>
<Col span={8}>
{formatPriceNumber.format(
parseFloat(enriched.liquidityA) / parseFloat(enriched.liquidityB)
)}
</Col>
<Col span={8}>
{formatPriceNumber.format(
parseFloat(enriched.liquidityB) / parseFloat(enriched.liquidityA)
)}
</Col>
<Col span={8}>
{ratio * 100 < 0.001 && ratio > 0 ? "<" : ""}
&nbsp;{formatPriceNumber.format(ratio * 100)}%
</Col>
</Row>
<Row style={{ width: "100%" }}>
<Col span={8}>
{enriched.names[0]} per {enriched.names[1]}
</Col>
<Col span={8}>
{enriched.names[1]} per {enriched.names[0]}
</Col>
<Col span={8}>Share of pool</Col>
</Row>
</Card>
);
};
export const YourPosition = (props: { pool?: PoolInfo }) => {
const { pool } = props;
const pools = useMemo(() => [props.pool].filter((p) => p) as PoolInfo[], [
props.pool,
]);
const enriched = useEnrichedPools(pools)[0];
const { userAccounts } = useUserAccounts();
const lpMint = useMint(pool?.pubkeys.mint);
if (!pool || !enriched) {
return null;
}
const baseMintAddress = pool.pubkeys.holdingMints[0].toBase58();
const quoteMintAddress = pool.pubkeys.holdingMints[1].toBase58();
const ratio =
userAccounts
.filter((f) => pool.pubkeys.mint.equals(f.info.mint))
.reduce((acc, item) => item.info.amount.toNumber() + acc, 0) /
(lpMint?.supply.toNumber() || 0);
return (
<Card
className="ccy-input"
style={{ borderRadius: 20, width: "100%" }}
bodyStyle={{ padding: "7px" }}
size="small"
title="Your Position"
>
<div className="pool-card" style={{ width: "initial" }}>
<div className="pool-card-row">
<div className="pool-card-cell">
<div style={{ display: "flex", alignItems: "center" }}>
<PoolIcon mintA={baseMintAddress} mintB={quoteMintAddress} />
<h3 style={{ margin: 0 }}>{enriched?.name}</h3>
</div>
</div>
<div className="pool-card-cell">
<h3 style={{ margin: 0 }}>
{formatPriceNumber.format(ratio * enriched.supply)}
</h3>
</div>
</div>
<div className="pool-card-row" style={{ margin: 0 }}>
<div className="pool-card-cell">Your Share:</div>
<div className="pool-card-cell">
{ratio * 100 < 0.001 && ratio > 0 ? "<" : ""}
{formatPriceNumber.format(ratio * 100)}%
</div>
</div>
<div className="pool-card-row" style={{ margin: 0 }}>
<div className="pool-card-cell">{enriched.names[0]}:</div>
<div className="pool-card-cell">
{formatPriceNumber.format(ratio * enriched.liquidityA)}
</div>
</div>
<div className="pool-card-row" style={{ margin: 0 }}>
<div className="pool-card-cell">{enriched.names[1]}:</div>
<div className="pool-card-cell">
{formatPriceNumber.format(ratio * enriched.liquidityB)}
</div>
</div>
</div>
</Card>
);
};

View File

@ -0,0 +1,111 @@
import React from "react";
import { Button, Col, Popover, Row } from "antd";
import { PoolInfo } from "../../models";
import { CopyOutlined, InfoCircleOutlined } from "@ant-design/icons";
import { ExplorerLink } from "./../explorerLink";
const Address = (props: {
address: string;
style?: React.CSSProperties;
label?: string;
}) => {
return (
<Row style={{ width: "100%", ...props.style }}>
{props.label && <Col span={4}>{props.label}:</Col>}
<Col span={17}>
<ExplorerLink address={props.address} code={true} type="address" />
</Col>
<Col span={3} style={{ display: "flex" }}>
<Button
shape="round"
icon={<CopyOutlined />}
size={"small"}
style={{ marginLeft: "auto", marginRight: 0 }}
onClick={() => navigator.clipboard.writeText(props.address)}
/>
</Col>
</Row>
);
};
export const PoolAddress = (props: {
pool?: PoolInfo;
style?: React.CSSProperties;
showLabel?: boolean;
label?: string;
}) => {
const { pool } = props;
const label = props.label || "Address";
if (!pool?.pubkeys.account) {
return null;
}
return (
<Address
address={pool.pubkeys.account.toBase58()}
style={props.style}
label={label}
/>
);
};
export const AccountsAddress = (props: {
pool?: PoolInfo;
style?: React.CSSProperties;
aName?: string;
bName?: string;
}) => {
const { pool } = props;
const account1 = pool?.pubkeys.holdingAccounts[0];
const account2 = pool?.pubkeys.holdingAccounts[1];
return (
<>
{account1 && (
<Address
address={account1.toBase58()}
style={props.style}
label={props.aName}
/>
)}
{account2 && (
<Address
address={account2.toBase58()}
style={props.style}
label={props.bName}
/>
)}
</>
);
};
export const AdressesPopover = (props: {
pool?: PoolInfo;
aName?: string;
bName?: string;
}) => {
const { pool, aName, bName } = props;
return (
<Popover
placement="topRight"
title={"Addresses"}
trigger="hover"
content={
<>
<PoolAddress pool={pool} showLabel={true} label={"Pool"} />
<AccountsAddress pool={pool} aName={aName} bName={bName} />
</>
}
>
<Button
shape="circle"
size="large"
type="text"
className={"trade-address-info-button"}
icon={<InfoCircleOutlined />}
/>
</Popover>
);
};

View File

@ -0,0 +1,208 @@
import React, { useMemo } from "react";
import { Card, Typography } from "antd";
import { RemoveLiquidity } from "./remove";
import { useMint, useUserAccounts } from "../../utils/accounts";
import { PoolIcon } from "../tokenIcon";
import { PoolInfo, TokenAccount } from "../../models";
import "./view.less";
import { useEnrichedPools } from "../../context/market";
import { formatNumber, formatPct, formatUSD } from "../../utils/utils";
import { ExplorerLink } from "../explorerLink";
import { SupplyOverview } from "./supplyOverview";
const { Text } = Typography;
export const PoolCard = (props: { pool: PoolInfo, account?: TokenAccount }) => {
const pools = useMemo(() => [props.pool].filter((p) => p) as PoolInfo[], [
props.pool,
]);
const enriched = useEnrichedPools(pools)[0];
const { userAccounts } = useUserAccounts();
const pool = props.pool;
const account = props.account;
const baseMintAddress = pool.pubkeys.holdingMints[0].toBase58();
const quoteMintAddress = pool.pubkeys.holdingMints[1].toBase58();
const lpMint = useMint(pool.pubkeys.mint);
const ratio = (account?.info.amount.toNumber() || 0) /
(lpMint?.supply.toNumber() || 0);
if (!enriched) {
return null;
}
const small: React.CSSProperties = { fontSize: 11 };
const userInfo = userAccounts.length > 0 && (
<>
<div className="pool-card-row">
<Text type="secondary" className="pool-card-cell ">
Your liquidity:
</Text>
<div className="pool-card-cell ">
<div className="left">
<div>{formatUSD.format(ratio * enriched.liquidity)}</div>
<div>
<Text type="secondary" style={small}>
{formatNumber.format(ratio * enriched.liquidityA)}{" "}
{enriched.names[0]}
</Text>
</div>
<div>
<Text type="secondary" style={small}>
{formatNumber.format(ratio * enriched.liquidityB)}{" "}
{enriched.names[1]}
</Text>
</div>
</div>
</div>
</div>
<div className="pool-card-row">
<Text type="secondary" className="pool-card-cell ">
Your quantity:
</Text>
<div className="pool-card-cell ">{ratio * enriched.supply}</div>
</div>
<div className="pool-card-row">
<Text type="secondary" className="pool-card-cell ">
Your fees (24h):
</Text>
<div className="pool-card-cell " title={`${enriched.fees24h * ratio}`}>
{enriched.fees24h * ratio < 0.005 ? "< " : ""}
{formatUSD.format(enriched.fees24h * ratio)}
</div>
</div>
<hr />
</>
);
return (
<Card
className="pool-card"
title={
<>
<PoolIcon
mintA={baseMintAddress}
mintB={quoteMintAddress}
className="left-icon"
/>
{enriched?.name}
</>
}
>
{userInfo}
<div className="pool-card-row">
<Text type="secondary" className="pool-card-cell ">
Pool Liquidity:
</Text>
<div className="pool-card-cell ">
<div className="left">
<div>{formatUSD.format(enriched.liquidity)}</div>
<div>
<Text type="secondary" style={small}>
{formatNumber.format(enriched.liquidityA)} {enriched.names[0]}
</Text>
</div>
<div>
<Text type="secondary" style={small}>
{formatNumber.format(enriched.liquidityB)} {enriched.names[1]}
</Text>
</div>
</div>
</div>
</div>
<div className="pool-card-row">
<Text type="secondary" className="pool-card-cell ">
LP Supply:
</Text>
<div className="pool-card-cell " title={enriched.supply}>
{formatNumber.format(enriched.supply)}
</div>
</div>
<div className="pool-card-row">
<Text type="secondary" className="pool-card-cell ">
Value per token:
</Text>
<div className="pool-card-cell ">
{formatUSD.format(enriched.liquidity / enriched.supply)}
</div>
</div>
<div className="pool-card-row">
<Text type="secondary" className="pool-card-cell ">
Volume (24h):
</Text>
<div className="pool-card-cell ">
{formatUSD.format(enriched.volume24h)}
</div>
</div>
<div className="pool-card-row">
<Text type="secondary" className="pool-card-cell ">
Fees (24h):
</Text>
<div className="pool-card-cell ">
{formatUSD.format(enriched.fees24h)}
</div>
</div>
<div className="pool-card-row">
<Text type="secondary" className="pool-card-cell ">
Approx. APY (24h):
</Text>
<div className="pool-card-cell ">
{formatPct.format(enriched.apy24h)}
</div>
</div>
<div className="pool-card-row">
<Text type="secondary" className="pool-card-cell ">
Address:
</Text>
<div className="pool-card-cell ">
<div className="left">
<div>
<ExplorerLink
address={enriched.address}
type="account"
length={4}
/>
</div>
<div className="small">
<ExplorerLink
address={pool.pubkeys.holdingAccounts[0]}
type="account"
style={small}
length={4}
/>
<Text type="secondary" style={small}>
{" "}
{enriched.names[0]}
</Text>
</div>
<div className="small">
<ExplorerLink
address={pool.pubkeys.holdingAccounts[1]}
type="account"
style={small}
length={4}
/>
<Text type="secondary" style={small}>
{" "}
{enriched.names[1]}
</Text>
</div>
</div>
</div>
</div>
<SupplyOverview pool={pool} />
<div className="pool-card-row">
{/* {item && <Button type="default" onClick={setPair}>Add</Button>} */}
{props.account && (
<RemoveLiquidity instance={{ pool, account: props.account }} />
)}
</div>
</Card>
);
};

View File

@ -0,0 +1,127 @@
import React, { useState } from "react";
import { Card, Select } from "antd";
import { NumericInput } from "../numericInput";
import "./add.less";
import { PoolConfig } from "../../models";
const Option = Select.Option;
export const DEFAULT_DENOMINATOR = 10_000;
const FeeInput = (props: {
numerator: number;
denominator: number;
set: (numerator: number, denominator: number) => void;
}) => {
const [value, setValue] = useState(
((props.numerator / props.denominator) * 100).toString()
);
return (
<div style={{ padding: "3px 10px 3px 3px", border: "1px solid #434343" }}>
<NumericInput
className="slippage-input"
size="small"
value={value}
style={{
width: 50,
fontSize: 14,
boxShadow: "none",
borderColor: "transparent",
outline: "transpaernt",
}}
onChange={(x: any) => {
setValue(x);
const val = parseFloat(x);
if (Number.isFinite(val)) {
const numerator = (val * DEFAULT_DENOMINATOR) / 100;
props.set(numerator, DEFAULT_DENOMINATOR);
}
}}
/>
%
</div>
);
};
// sets fee in the pool to 0.3%
// see for fees details: https://uniswap.org/docs/v2/advanced-topics/fees/
export const PoolConfigCard = (props: {
options: PoolConfig;
setOptions: (config: PoolConfig) => void;
}) => {
const {
tradeFeeNumerator,
tradeFeeDenominator,
ownerTradeFeeNumerator,
ownerTradeFeeDenominator,
ownerWithdrawFeeNumerator,
ownerWithdrawFeeDenominator,
} = props.options;
return (
<Card title="Pool configuration">
<div className="pool-settings-grid">
<>
<span>LPs Trading Fee:</span>
<FeeInput
numerator={tradeFeeNumerator}
denominator={tradeFeeDenominator}
set={(numerator, denominator) =>
props.setOptions({
...props.options,
tradeFeeNumerator: numerator,
tradeFeeDenominator: denominator,
})
}
/>
</>
<>
<span>Owner Trading Fee:</span>
<FeeInput
numerator={ownerTradeFeeNumerator}
denominator={ownerTradeFeeDenominator}
set={(numerator, denominator) =>
props.setOptions({
...props.options,
ownerTradeFeeNumerator: numerator,
ownerTradeFeeDenominator: denominator,
})
}
/>
</>
<>
<span>Withdraw Fee:</span>
<FeeInput
numerator={ownerWithdrawFeeNumerator}
denominator={ownerWithdrawFeeDenominator}
set={(numerator, denominator) =>
props.setOptions({
...props.options,
ownerWithdrawFeeNumerator: numerator,
ownerWithdrawFeeDenominator: denominator,
})
}
/>
</>
<>
<span>Curve Type:</span>
<Select
defaultValue="0"
style={{ width: 200 }}
onChange={(val) =>
props.setOptions({
...props.options,
curveType: parseInt(val) as any,
})
}
>
<Option value="0">Constant Product</Option>
<Option value="1">Flat</Option>
</Select>
</>
</div>
</Card>
);
};

View File

@ -0,0 +1,39 @@
.pools-grid {
display: flex;
flex-direction: column;
}
.pool-item-header {
display: flex;
position: relative;
width: 100%;
align-items: center;
text-align: right;
}
.pool-item-row {
display: flex;
position: relative;
width: 100%;
align-items: center;
text-align: right;
cursor: pointer;
}
.pool-item-row:hover {
background-color: var(--row-highlight);
}
.pool-item-amount {
width: 100px;
text-align: right;
}
.pool-item-type {
width: 20px;
}
.pool-item-name {
padding-right: 0.5em;
width: 85px;
}

View File

@ -0,0 +1,115 @@
import React, { useMemo } from "react";
import { ConfigProvider, Empty } from "antd";
import { useOwnedPools } from "../../utils/pools";
import { RemoveLiquidity } from "./remove";
import { useMint } from "../../utils/accounts";
import { PoolIcon } from "../tokenIcon";
import { PoolInfo, TokenAccount } from "../../models";
import { useCurrencyPairState } from "../../utils/currencyPair";
import "./quickView.less";
import { useEnrichedPools } from "../../context/market";
import { formatUSD } from "../../utils/utils";
import { useHistory, useLocation } from "react-router-dom";
const PoolItem = (props: {
item: { pool: PoolInfo; isFeeAccount: boolean; account: TokenAccount };
poolDetails: any;
}) => {
const { A, B } = useCurrencyPairState();
const history = useHistory();
const item = props.item;
const mint = useMint(item.account.info.mint.toBase58());
const amount =
item.account.info.amount.toNumber() / Math.pow(10, mint?.decimals || 0);
const supply = mint?.supply.toNumber() || 0;
const poolContribution = item.account.info.amount.toNumber() / supply;
const contributionInUSD = poolContribution * props.poolDetails?.liquidity;
const feesInUSD = poolContribution * props.poolDetails?.fees;
// amount / supply * liquidity
if (!amount) {
return null;
}
const setPair = () => {
// navigate to pool info
A.setMint(props.item.pool.pubkeys.holdingMints[0]?.toBase58());
B.setMint(props.item.pool.pubkeys.holdingMints[1]?.toBase58());
history.push({
pathname: "/pool",
});
};
const sorted = item.pool.pubkeys.holdingMints.map((a) => a.toBase58()).sort();
if (item) {
return (
<div
className="pool-item-row"
onClick={setPair}
title={`LP Token: ${props.item.pool.pubkeys.mint.toBase58()}`}
>
<PoolIcon
mintA={sorted[0]}
mintB={sorted[1]}
style={{ marginLeft: "0.5rem" }}
/>
<div className="pool-item-name">{props.poolDetails?.name}</div>
<div className="pool-item-amount">
{formatUSD.format(contributionInUSD)}
</div>
<div className="pool-item-amount">{formatUSD.format(feesInUSD)}</div>
<div className="pool-item-type" title="Fee account">
{item.isFeeAccount ? " (F) " : " "}
</div>
<RemoveLiquidity instance={item} />
</div>
);
}
return null;
};
export const PoolAccounts = () => {
const pools = useOwnedPools();
const userPools = useMemo(() => {
return pools.map((p) => p.pool);
}, [pools]);
const enriched = useEnrichedPools(userPools);
return (
<>
<div>Your Liquidity</div>
<ConfigProvider
renderEmpty={() => (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description="No liquidity found."
/>
)}
>
<div className="pools-grid">
<div className="pool-item-header">
<div style={{ width: 48 }} />
<div className="pool-item-name">Pool</div>
<div className="pool-item-amount">Liquidity</div>
<div className="pool-item-amount">Fees</div>
<div className="pool-item-type" />
<div />
</div>
{pools.map((p) => (
<PoolItem
key={p?.account.pubkey.toBase58()}
item={p as any}
poolDetails={enriched.find((e) => e.raw === p.pool)}
/>
))}
</div>
</ConfigProvider>
</>
);
};

View File

@ -0,0 +1,44 @@
import React, { useState } from "react";
import { Button } from "antd";
import { removeLiquidity } from "../../utils/pools";
import { useWallet } from "../../utils/wallet";
import { useConnection } from "../../utils/connection";
import { PoolInfo, TokenAccount } from "../../models";
import { notify } from "../../utils/notifications";
export const RemoveLiquidity = (props: {
instance: { account: TokenAccount; pool: PoolInfo };
}) => {
const { account, pool } = props.instance;
const [pendingTx, setPendingTx] = useState(false);
const { wallet } = useWallet();
const connection = useConnection();
const onRemove = async () => {
try {
setPendingTx(true);
// TODO: calculate percentage based on user input
let liquidityAmount = account.info.amount.toNumber();
await removeLiquidity(connection, wallet, liquidityAmount, account, pool);
} catch {
notify({
description:
"Please try again and approve transactions from your wallet",
message: "Removing liquidity cancelled.",
type: "error",
});
} finally {
setPendingTx(false);
// TODO: force refresh of pool accounts?
}
};
return (
<>
<Button type="default" onClick={onRemove} disabled={pendingTx}>
Remove
</Button>
</>
);
};

View File

@ -0,0 +1,100 @@
import React, { useEffect, useMemo, useRef } from "react";
import { PoolInfo } from "../../models";
import { useEnrichedPools } from "./../../context/market";
import echarts from "echarts";
import { formatNumber, formatUSD } from "../../utils/utils";
export const SupplyOverview = (props: { pool?: PoolInfo }) => {
const { pool } = props;
const pools = useMemo(() => (pool ? [pool] : []), [pool]);
const enriched = useEnrichedPools(pools);
const chartDiv = useRef<HTMLDivElement>(null);
// dispose chart
useEffect(() => {
const div = chartDiv.current;
return () => {
let instance = div && echarts.getInstanceByDom(div);
instance && instance.dispose();
};
}, []);
useEffect(() => {
if (!chartDiv.current || enriched.length === 0) {
return;
}
let instance = echarts.getInstanceByDom(chartDiv.current);
if (!instance) {
instance = echarts.init(chartDiv.current as any);
}
const data = [
{
name: enriched[0].names[0],
value: enriched[0].liquidityAinUsd,
tokens: enriched[0].liquidityA,
},
{
name: enriched[0].names[1],
value: enriched[0].liquidityBinUsd,
tokens: enriched[0].liquidityB,
},
];
instance.setOption({
tooltip: {
trigger: "item",
formatter: function (params: any) {
var val = formatUSD.format(params.value);
var tokenAmount = formatNumber.format(params.data.tokens);
return `${params.name}: \n${val}\n(${tokenAmount})`;
},
},
series: [
{
name: "Liquidity",
type: "pie",
top: 0,
bottom: 0,
left: 0,
right: 0,
animation: false,
label: {
fontSize: 14,
show: true,
formatter: function (params: any) {
var val = formatUSD.format(params.value);
var tokenAmount = formatNumber.format(params.data.tokens);
return `{c|${params.name}}\n{r|${tokenAmount}}\n{r|${val}}`;
},
rich: {
c: {
color: "#999",
lineHeight: 22,
align: "center",
},
r: {
color: "#999",
align: "right",
},
},
color: "rgba(255, 255, 255, 0.5)",
},
itemStyle: {
normal: {
borderColor: "#000",
},
},
data,
},
],
});
}, [enriched]);
if (enriched.length === 0) {
return null;
}
return <div ref={chartDiv} style={{ height: 150, width: "100%" }} />;
};

View File

@ -0,0 +1,64 @@
.pool-grid {
display: flex;
flex-wrap: wrap;
justify-content: center;
}
.pool-card {
display: flex;
flex-direction: column;
width: 400px;
position: relative;
margin: 5px;
.left-icon {
position: absolute;
top: 15px;
left: 30px;
transform: scale(1.5, 1.5);
}
h3 {
display: flex;
}
hr {
border-color: gray;
}
.pool-card-row {
box-sizing: border-box;
margin: 5px 0px;
min-width: 0px;
width: 100%;
display: flex;
flex-direction: row;
padding: 0px;
-webkit-box-align: center;
align-items: center;
-webkit-box-pack: justify;
justify-content: space-between;
.pool-card-cell {
display: flex;
flex-direction: column;
align-items: flex-end;
box-sizing: border-box;
text-align: left;
margin: 0px;
min-width: 0px;
font-size: 14px;
font-weight: 500;
}
.left {
display: flex;
flex-direction: column;
align-items: flex-end;
}
.small {
font-size: 11px;
}
}
}

View File

@ -0,0 +1,42 @@
import React from "react";
import { Button, Popover } from "antd";
import { useOwnedPools } from "../../utils/pools";
import "./view.less";
import { Settings } from "./../settings";
import { SettingOutlined } from "@ant-design/icons";
import { AppBar } from "./../appBar";
import { useWallet } from "../../utils/wallet";
import { PoolCard } from "./card";
export const PoolOverview = () => {
const owned = useOwnedPools();
const { connected } = useWallet();
return (
<>
<AppBar
right={
<Popover
placement="topRight"
title="Settings"
content={<Settings />}
trigger="click"
>
<Button
shape="circle"
size="large"
type="text"
icon={<SettingOutlined />}
/>
</Popover>
}
/>
<div className="pool-grid">
{owned.map((o) => (
<PoolCard pool={o.pool} account={o.account} />
))}
{!connected && <h3>Connect to a wallet to view your liquidity.</h3>}
</div>
</>
);
};

View File

@ -0,0 +1,484 @@
import React, { useState, useEffect } from "react";
import {
Account,
PublicKey,
sendAndConfirmRawTransaction,
SystemProgram,
Transaction,
TransactionInstruction
} from '@solana/web3.js';
import {TokenInstructions} from '@project-serum/serum';
import BufferLayout from 'buffer-layout';
import {AccountLayout} from '@solana/spl-token';
import { Button, Card, Popover, Col, Row } from "antd";
import { NumericInput } from "./numericInput";
import { Settings } from "./settings";
import { SettingOutlined } from "@ant-design/icons";
import { AppBar } from "./appBar";
import contract_keys from "../contract_keys.json";
import { useMint } from '../utils/accounts';
import { useConnection } from '../utils/connection';
import { useWallet } from '../utils/wallet';
const PROGRAM_ID = new PublicKey(contract_keys.omega_program_id);
console.log('PROGRAM_ID', PROGRAM_ID.toString());
const QUOTE_CURRENCY = "USDC";
const QUOTE_CURRENCY_MINT = new PublicKey(contract_keys.quote_mint_pk);
console.log('QUOTE_CURRENCY', QUOTE_CURRENCY, QUOTE_CURRENCY_MINT.toString());
const CONTRACT_NAME = contract_keys.contract_name;
const OMEGA_CONTRACT = new PublicKey(contract_keys.omega_contract_pk);
const OMEGA_QUOTE_VAULT = new PublicKey(contract_keys.quote_vault_pk);
console.log('MARKET', CONTRACT_NAME, OMEGA_QUOTE_VAULT.toString());
/* INSTRUCTIONS
* define buffer layouts and factory functions
*/
const MAX_OUTCOMES = 2;
const DETAILS_BUFFER_LEN = 2048;
const marketContractLayout = BufferLayout.struct([
BufferLayout.nu64('flags'),
BufferLayout.blob(32, 'oracle'),
BufferLayout.blob(32, 'quote_mint'),
BufferLayout.nu64('exp_time'),
BufferLayout.blob(32, 'vault'),
BufferLayout.blob(32, 'signer_key'),
BufferLayout.nu64('signer_nonce'),
BufferLayout.blob(32, 'winner'),
BufferLayout.blob(32, 'outcome0'),
BufferLayout.blob(32, 'outcome1'),
BufferLayout.nu64('num_outcomes'),
BufferLayout.blob(DETAILS_BUFFER_LEN, 'details')
]);
async function queryMarketContract(conn) {
const accountInfo = await conn.getParsedAccountInfo(OMEGA_CONTRACT, 'singleGossip');
const result = marketContractLayout.decode(Buffer.from(accountInfo.value.data));
console.log('QUERY', CONTRACT_NAME, result);
return result;
};
const IC_ISSUE_SET = 1;
const IC_REDEEM_SET = 2;
const IC_REDEEM_WINNER = 3;
const instructionLayout = BufferLayout.struct([
BufferLayout.u32('instruction'),
BufferLayout.nu64('quantity'),
]);
function encodeInstructionData(layout, args) {
let data = Buffer.alloc(1024);
const encodeLength = layout.encode(args, data);
return data.slice(0, encodeLength);
}
function IssueSetInstruction(omegaContract, user, userQuote, vault, omegaSigner, outcomePks, quantity) {
let keys = [
{ pubkey: omegaContract, isSigner: false, isWritable: false },
{ pubkey: user, isSigner: true, isWritable: false },
{ pubkey: userQuote, isSigner: false, isWritable: true },
{ pubkey: vault, isSigner: false, isWritable: true },
{ pubkey: TokenInstructions.TOKEN_PROGRAM_ID, isSigner: false, isWritable: false },
{ pubkey: omegaSigner, isSigner: false, isWritable: false }
];
for (var i = 0; i < outcomePks.length; i++) {
keys.push({pubkey: outcomePks[i], isSigner: false, isWritable: true});
}
const data = encodeInstructionData(instructionLayout, {
instruction: IC_ISSUE_SET,
quantity
});
return new TransactionInstruction({keys: keys, programId: PROGRAM_ID, data: data});
}
function RedeemSetInstruction(omegaContract, user, userQuote, vault, omegaSigner, outcomePks, quantity) {
let keys = [
{ pubkey: omegaContract, isSigner: false, isWritable: false },
{ pubkey: user, isSigner: true, isWritable: false },
{ pubkey: userQuote, isSigner: false, isWritable: true },
{ pubkey: vault, isSigner: false, isWritable: true },
{ pubkey: TokenInstructions.TOKEN_PROGRAM_ID, isSigner: false, isWritable: false },
{ pubkey: omegaSigner, isSigner: false, isWritable: false }
];
for (var i = 0; i < outcomePks.length; i++) {
keys.push({pubkey: outcomePks[i], isSigner: false, isWritable: true});
}
const data = encodeInstructionData(instructionLayout, {
instruction: IC_REDEEM_SET,
quantity
});
return new TransactionInstruction({keys: keys, programId: PROGRAM_ID, data: data});
}
function RedeemWinnerInstruction(omegaContract, user, userQuote, vault, omegaSigner, winnerMint, winnerWallet, quantity) {
let keys = [
{ pubkey: omegaContract, isSigner: false, isWritable: false },
{ pubkey: user, isSigner: true, isWritable: false },
{ pubkey: userQuote, isSigner: false, isWritable: true },
{ pubkey: vault, isSigner: false, isWritable: true },
{ pubkey: TokenInstructions.TOKEN_PROGRAM_ID, isSigner: false, isWritable: false },
{ pubkey: omegaSigner, isSigner: false, isWritable: false },
{ pubkey: winnerMint, isSigner: false, isWritable: true},
{ pubkey: winnerWallet, isSigner: false, isWritable: true},
];
const data = encodeInstructionData(instructionLayout, {
instruction: IC_REDEEM_WINNER,
quantity
});
return new TransactionInstruction({keys: keys, programId: PROGRAM_ID, data: data});
}
/* utils */
async function signTransaction(
transaction,
wallet,
signers,
connection
) {
transaction.recentBlockhash = (await connection.getRecentBlockhash('max')).blockhash;
transaction.setSigners(wallet.publicKey, ...signers.map((s) => s.publicKey));
if (signers.length > 0) {
transaction.partialSign(...signers);
}
let res = await wallet.signTransaction(transaction);
return res;
}
async function sendTransaction(
transaction,
wallet,
signers,
connection
) {
const signedTransaction = await signTransaction(transaction, wallet, signers, connection);
return await sendSignedTransaction(signedTransaction, connection);
}
const getUnixTs = () => {
return new Date().getTime() / 1000;
};
async function sendSignedTransaction(
signedTransaction,
connection
) {
const rawTransaction = signedTransaction.serialize();
return await sendAndConfirmRawTransaction(connection, rawTransaction, {commitment: "singleGossip"})
}
export const RedeemView = (props) => {
let connection = useConnection();
const { wallet, connected } = useWallet();
const quoteMint = useMint(contract_keys.quote_mint_pk);
const [contractData, setContractData] = useState({
exp_time: 1612137600 // 02/01/2021 00:00 UTC
});
// TODO: test once we have a demo contract deployed
useEffect(() => {
async function fetchContractData() {
let data = await queryMarketContract(connection);
let winner = new PublicKey(data.winner);
let zeroPubkey = new PublicKey(new Uint8Array(32));
data['decided'] = winner === zeroPubkey;
setContractData(data);
};
fetchContractData();
}, [connection]);
useEffect(() => {
console.log('contract.exp_time', new Date(contractData.exp_time * 1000));
console.log('contract.decided', contractData.decided);
}, [contractData]);
async function fetchAccounts() {
console.log('Fetch all SPL tokens for', wallet.publicKey.toString());
const response = await connection.getParsedTokenAccountsByOwner(
wallet.publicKey,
{ programId: TokenInstructions.TOKEN_PROGRAM_ID }
);
console.log(response.value.length, 'SPL tokens found', response);
response.value.map((a) => a.account.data.parsed.info).forEach((info, _) => {
console.log(info.mint, info.tokenAmount.uiAmount);
});
return response.value;
}
async function createTokenAccountTransaction(mintPubkey) {
const newAccount = new Account();
const transaction = new Transaction();
transaction.add(
SystemProgram.createAccount({
fromPubkey: wallet.publicKey,
newAccountPubkey: newAccount.publicKey,
lamports: await connection.getMinimumBalanceForRentExemption(AccountLayout.span),
space: AccountLayout.span,
programId: TokenInstructions.TOKEN_PROGRAM_ID,
})
);
transaction.add(
TokenInstructions.initializeAccount({
account: newAccount.publicKey,
mint: mintPubkey,
owner: wallet.publicKey,
}),
);
return {
transaction,
signer: newAccount,
newAccountPubkey: newAccount.publicKey,
};
}
async function userTokenAccount(accounts, mintPubkey) {
let account = accounts.find(a => a.account.data.parsed.info.mint === mintPubkey.toBase58())
if (account) {
console.log('account exists', mintPubkey.toString(), account.pubkey.toString());
return account.pubkey;
} else {
console.log('creating new account for', mintPubkey.toString());
let { transaction, signer, newAccountPubkey } = await createTokenAccountTransaction(mintPubkey);
let txid = await sendTransaction(transaction, wallet, [signer], connection);
console.log("txid", txid);
console.log('pubkey', newAccountPubkey.toString());
return newAccountPubkey;
}
}
function parseAmount(amount) {
return parseFloat(amount) * Math.pow(10, quoteMint.decimals);
}
async function issueSet(amount) {
if (!wallet.connected) await wallet.connect();
console.log('issueSet', amount);
const accounts = await fetchAccounts();
let omegaSigner = new PublicKey(contract_keys.signer_pk);
let userQuote = await userTokenAccount(accounts, QUOTE_CURRENCY_MINT);
let outcomePks = [];
let outcomeInfos = contract_keys["outcomes"];
let numOutcomes = outcomeInfos.length;
for (let i = 0; i < numOutcomes; i++) {
let outcomeMint = new PublicKey(outcomeInfos[i]["mint_pk"]);
outcomePks.push(outcomeMint);
let userOutcomeWallet = await userTokenAccount(accounts, outcomeMint);
outcomePks.push(userOutcomeWallet);
console.log(outcomeInfos[i]["name"], outcomeMint, userOutcomeWallet);
}
let issueSetInstruction = IssueSetInstruction(OMEGA_CONTRACT, wallet.publicKey, userQuote, OMEGA_QUOTE_VAULT,
omegaSigner, outcomePks, amount);
let transaction = new Transaction();
transaction.add(issueSetInstruction);
let txid = await sendTransaction(transaction, wallet, [], connection);
console.log('success txid:', txid);
}
async function redeemSet(amount) {
if (!wallet.connected) await wallet.connect();
console.log('redeemSet', amount);
const accounts = await fetchAccounts();
let omegaSigner = new PublicKey(contract_keys.signer_pk);
let userQuote = await userTokenAccount(accounts, QUOTE_CURRENCY_MINT);
let outcomePks = [];
let outcomeInfos = contract_keys["outcomes"];
let numOutcomes = outcomeInfos.length;
for (let i = 0; i < numOutcomes; i++) {
let outcomeMint = new PublicKey(outcomeInfos[i]["mint_pk"]);
outcomePks.push(outcomeMint);
let userOutcomeWallet = await userTokenAccount(accounts, outcomeMint);
outcomePks.push(userOutcomeWallet);
console.log(outcomeInfos[i]["name"], outcomeMint, userOutcomeWallet);
}
let redeemSetInstruction = RedeemSetInstruction(OMEGA_CONTRACT, wallet.publicKey, userQuote, OMEGA_QUOTE_VAULT,
omegaSigner, outcomePks, amount);
let transaction = new Transaction();
transaction.add(redeemSetInstruction);
let txid = await sendTransaction(transaction, wallet, [], connection);
console.log('success txid:', txid);
}
async function redeemWinner(amount) {
if (!wallet.connected) await wallet.connect();
console.log('redeemWinner', amount);
const accounts = await fetchAccounts();
let winner = new PublicKey(contractData.winner);
console.log(winner);
let zeroPubkey = new PublicKey(new Uint8Array(32));
if (winner === zeroPubkey) {
console.log("Contract has not been resolved yet");
return;
}
let winnerWallet = await userTokenAccount(accounts, winner);
let userQuote = await userTokenAccount(accounts, QUOTE_CURRENCY_MINT);
let omegaSigner = new PublicKey(contract_keys.signer_pk);
let redeemWinnerInstruction = RedeemWinnerInstruction(OMEGA_CONTRACT, wallet.publicKey, userQuote,
OMEGA_QUOTE_VAULT, omegaSigner, winner, winnerWallet, amount);
let transaction = new Transaction();
transaction.add(redeemWinnerInstruction);
let txid = await sendTransaction(transaction, wallet, [], connection);
console.log('success txid:', txid);
}
const colStyle = { padding: "0.5em", width: 256+128 };
const [winnerAmount, setWinnerAmount] = useState("");
const [redeemAmount, setRedeemAmount] = useState("");
const [issueAmount, setIssueAmount] = useState("");
return (
<>
<AppBar
right={
<Popover
placement="topRight"
title="Settings"
content={<Settings />}
trigger="click"
>
<Button
shape="circle"
size="large"
type="text"
icon={<SettingOutlined />}
/>
</Popover>
}
/>
<Row justify="center">
<Col>
<div style={colStyle}>
<Card>
<h2>Redeem Winner</h2>
<p>After the oracle has resolved the contract, you may redeem the winning token for equal quantities of USDC.</p>
<NumericInput
value={winnerAmount}
onChange={setWinnerAmount}
style={{
"margin-bottom": 10,
}}
placeholder="0.00"
disabled={!contractData.decided}
/>
<Button
className="trade-button"
type="primary"
onClick={connected ? () => redeemWinner(parseAmount(winnerAmount)) : wallet.connect}
style={{ width: "100%" }}
disabled={!contractData.decided}
>
{ connected ? "Redeem Tokens" : "Connect Wallet" }
</Button>
</Card>
</div>
</Col>
<Col>
<div style={colStyle}>
<Card>
<h2>Redeem Set</h2>
<p>Swap {contract_keys.contract_name} {contract_keys.outcomes[0].name} and {contract_keys.contract_name} {contract_keys.outcomes[1].name} for equal quantities of USDC.</p>
<NumericInput
value={redeemAmount}
onChange={setRedeemAmount}
style={{
"margin-bottom": 10,
}}
addonAfter={`${contract_keys.outcomes[0].name} & ${contract_keys.outcomes[1].name}`}
placeholder="0.00"
/>
<Button
className="trade-button"
type="primary"
onClick={connected ? () => redeemSet(parseAmount(redeemAmount)) : wallet.connect}
style={{ width: "100%" }}
>
{ connected ? "Redeem Tokens" : "Connect Wallet" }
</Button>
</Card>
</div>
</Col>
<Col>
<div style={colStyle}>
<Card>
<h2>Issue Set</h2>
<p>Swap USDC for equal quantities of {contract_keys.contract_name} {contract_keys.outcomes[0].name} and {contract_keys.contract_name} {contract_keys.outcomes[1].name} tokens.</p>
<NumericInput
value={issueAmount}
onChange={setIssueAmount}
style={{
"margin-bottom": 10,
}}
addonAfter="USDC"
placeholder="0.00"
/>
<Button
className="trade-button"
type="primary"
onClick={connected ? () => issueSet(parseAmount(issueAmount)) : wallet.connect}
style={{ width: "100%" }}
>
{ connected ? "Issue Tokens" : "Connect Wallet" }
</Button>
</Card>
</div>
</Col>
</Row>
</>
);
};

View File

@ -0,0 +1,46 @@
import React from "react";
import { Select } from "antd";
import { ENDPOINTS, useConnectionConfig } from "../utils/connection";
import { useWallet, WALLET_PROVIDERS } from "../utils/wallet";
import { Slippage } from "./slippage";
export const Settings = () => {
const { providerUrl, setProvider } = useWallet();
const { endpoint, setEndpoint } = useConnectionConfig();
return (
<>
<div>
Transactions: Settings:
<div>
Slippage:
<Slippage />
</div>
</div>
<div style={{ display: "grid" }}>
Network:{" "}
<Select
onSelect={setEndpoint}
value={endpoint}
style={{ marginRight: 8 }}
>
{ENDPOINTS.map(({ name, endpoint }) => (
<Select.Option value={endpoint} key={endpoint}>
{name}
</Select.Option>
))}
</Select>
</div>
<div style={{ display: "grid" }}>
Wallet:{" "}
<Select onSelect={setProvider} value={providerUrl}>
{WALLET_PROVIDERS.map(({ name, url }) => (
<Select.Option value={url} key={url}>
{name}
</Select.Option>
))}
</Select>
</div>
</>
);
};

View File

@ -0,0 +1,64 @@
import React, { useEffect, useState } from "react";
import { Button } from "antd";
import { useSlippageConfig } from "./../../utils/connection";
import { NumericInput } from "./../numericInput";
export const Slippage = () => {
const { slippage, setSlippage } = useSlippageConfig();
const slippagePct = slippage * 100;
const [value, setValue] = useState(slippagePct.toString());
useEffect(() => {
setValue(slippagePct.toString());
}, [slippage, slippagePct]);
const isSelected = (val: number) => {
return val === slippagePct ? "primary" : "default";
};
const itemStyle: React.CSSProperties = {
margin: 5,
};
return (
<div
style={{ display: "flex", flexDirection: "row", alignItems: "center" }}
>
{[0.1, 0.5, 1.0].map((item) => {
return (
<Button
key={item.toString()}
style={itemStyle}
type={isSelected(item)}
onClick={() => setSlippage(item / 100.0)}
>
{item}%
</Button>
);
})}
<div style={{ padding: "3px 10px 3px 3px", border: "1px solid #434343" }}>
<NumericInput
className="slippage-input"
size="small"
placeholder={value}
value={value}
style={{
width: 50,
fontSize: 14,
boxShadow: "none",
borderColor: "transparent",
outline: "transpaernt",
}}
onChange={(x: any) => {
setValue(x);
const newValue = parseFloat(x) / 100.0;
if (Number.isFinite(newValue)) {
setSlippage(newValue);
}
}}
/>
%
</div>
</div>
);
};

View File

@ -0,0 +1,2 @@
.slippage-input {
}

View File

@ -0,0 +1,46 @@
import React, { useState } from "react";
import { Card } from "antd";
import { TradeEntry } from "./trade";
import { AddToLiquidity } from "./pool/add";
export const SwapView = (props: {}) => {
const tabStyle: React.CSSProperties = { width: 120 };
const tabList = [
{
key: "trade",
tab: <div style={tabStyle}>Trade</div>,
render: () => {
return <TradeEntry />;
},
},
{
key: "pool",
tab: <div style={tabStyle}>Pool</div>,
render: () => {
return <AddToLiquidity />;
},
},
];
const [activeTab, setActiveTab] = useState(tabList[0].key);
return (
<>
<Card
className="exchange-card"
headStyle={{ padding: 0 }}
bodyStyle={{ position: "relative" }}
tabList={tabList}
tabProps={{
tabBarGutter: 0,
}}
activeTabKey={activeTab}
onTabChange={(key) => {
setActiveTab(key);
}}
>
{tabList.find((t) => t.key === activeTab)?.render()}
</Card>
</>);
};

View File

@ -0,0 +1,66 @@
import { Identicon } from "./../identicon";
import React from "react";
import { getTokenIcon } from "../../utils/utils";
import { useConnectionConfig } from "../../utils/connection";
export const TokenIcon = (props: {
mintAddress: string;
style?: React.CSSProperties;
className?: string;
}) => {
const { tokenMap } = useConnectionConfig();
const icon = getTokenIcon(tokenMap, props.mintAddress);
if (icon) {
return (
<img
alt="Token icon"
className={props.className}
key={props.mintAddress}
width="20"
height="20"
src={icon}
style={{
marginRight: "0.5rem",
marginTop: "0.11rem",
borderRadius: "1rem",
backgroundColor: "white",
backgroundClip: "padding-box",
...props.style,
}}
/>
);
}
return (
<Identicon
address={props.mintAddress}
style={{
marginRight: "0.5rem",
display: "flex",
alignSelf: "center",
width: 20,
height: 20,
marginTop: 2,
...props.style,
}}
/>
);
};
export const PoolIcon = (props: {
mintA: string;
mintB: string;
style?: React.CSSProperties;
className?: string;
}) => {
return (
<div className={props.className} style={{ display: "flex" }}>
<TokenIcon
mintAddress={props.mintA}
style={{ marginRight: "-0.5rem", ...props.style }}
/>
<TokenIcon mintAddress={props.mintB} />
</div>
);
};

View File

@ -0,0 +1,310 @@
import { Button, Popover, Spin, Typography } from "antd";
import React, { useEffect, useMemo, useState } from "react";
import {
useConnection,
useConnectionConfig,
useSlippageConfig,
} from "../../utils/connection";
import { useWallet } from "../../utils/wallet";
import { CurrencyInput } from "../currencyInput";
import {
LoadingOutlined,
SwapOutlined,
QuestionCircleOutlined,
} from "@ant-design/icons";
import {
swap,
usePoolForBasket,
PoolOperation,
LIQUIDITY_PROVIDER_FEE,
} from "../../utils/pools";
import { notify } from "../../utils/notifications";
import { useCurrencyPairState } from "../../utils/currencyPair";
import { generateActionLabel, POOL_NOT_AVAILABLE, SWAP_LABEL } from "../labels";
import "./trade.less";
import { colorWarning, getTokenName } from "../../utils/utils";
import { AdressesPopover } from "../pool/address";
import { PoolInfo } from "../../models";
import { useEnrichedPools } from "../../context/market";
const { Text } = Typography;
const antIcon = <LoadingOutlined style={{ fontSize: 24 }} spin />;
// TODO:
// Compute price breakdown with/without fee
// Show slippage
// Show fee information
export const TradeEntry = () => {
const { wallet, connected } = useWallet();
const connection = useConnection();
const [pendingTx, setPendingTx] = useState(false);
const {
A,
B,
setLastTypedAccount,
setPoolOperation,
} = useCurrencyPairState();
const pool = usePoolForBasket([A?.mintAddress, B?.mintAddress]);
console.log('SWAP pool', pool);
const { slippage } = useSlippageConfig();
const { tokenMap } = useConnectionConfig();
const swapAccounts = () => {
const tempMint = A.mintAddress;
const tempAmount = A.amount;
A.setMint(B.mintAddress);
A.setAmount(B.amount);
B.setMint(tempMint);
B.setAmount(tempAmount);
// @ts-ignore
setPoolOperation((op: PoolOperation) => {
switch (+op) {
case PoolOperation.SwapGivenInput:
return PoolOperation.SwapGivenProceeds;
case PoolOperation.SwapGivenProceeds:
return PoolOperation.SwapGivenInput;
case PoolOperation.Add:
return PoolOperation.SwapGivenInput;
}
});
};
const handleSwap = async () => {
if (A.account && B.mintAddress) {
try {
setPendingTx(true);
const components = [
{
account: A.account,
mintAddress: A.mintAddress,
amount: A.convertAmount(),
},
{
mintAddress: B.mintAddress,
amount: B.convertAmount(),
},
];
await swap(connection, wallet, components, slippage, pool);
} catch {
notify({
description:
"Please try again and approve transactions from your wallet",
message: "Swap trade cancelled.",
type: "error",
});
} finally {
setPendingTx(false);
}
}
};
return (
<>
<div className="input-card">
<AdressesPopover pool={pool} aName={A.name} bName={B.name} />
<CurrencyInput
title="Input"
onInputChange={(val: any) => {
setPoolOperation(PoolOperation.SwapGivenInput);
if (A.amount !== val) {
setLastTypedAccount(A.mintAddress);
}
A.setAmount(val);
}}
amount={A.amount}
mint={A.mintAddress}
onMintChange={(item) => {
A.setMint(item);
}}
/>
<Button type="primary" className="swap-button" onClick={swapAccounts}>
</Button>
<CurrencyInput
title="To (Estimate)"
onInputChange={(val: any) => {
setPoolOperation(PoolOperation.SwapGivenProceeds);
if (B.amount !== val) {
setLastTypedAccount(B.mintAddress);
}
B.setAmount(val);
}}
amount={B.amount}
mint={B.mintAddress}
onMintChange={(item) => {
B.setMint(item);
}}
/>
</div>
<Button
className="trade-button"
type="primary"
size="large"
onClick={connected ? handleSwap : wallet.connect}
style={{ width: "100%" }}
disabled={
connected &&
(pendingTx ||
!A.account ||
!B.mintAddress ||
A.account === B.account ||
!A.sufficientBalance() ||
!pool)
}
>
{generateActionLabel(
!pool
? POOL_NOT_AVAILABLE(
getTokenName(tokenMap, A.mintAddress),
getTokenName(tokenMap, B.mintAddress)
)
: SWAP_LABEL,
connected,
tokenMap,
A,
B,
true
)}
{pendingTx && <Spin indicator={antIcon} className="trade-spinner" />}
</Button>
<TradeInfo pool={pool} />
</>
);
};
export const TradeInfo = (props: { pool?: PoolInfo }) => {
const { A, B } = useCurrencyPairState();
const { pool } = props;
const { slippage } = useSlippageConfig();
const pools = useMemo(() => (pool ? [pool] : []), [pool]);
const enriched = useEnrichedPools(pools);
const [amountOut, setAmountOut] = useState(0);
const [priceImpact, setPriceImpact] = useState(0);
const [lpFee, setLpFee] = useState(0);
const [exchangeRate, setExchangeRate] = useState(0);
const [priceAccount, setPriceAccount] = useState("");
useEffect(() => {
if (!pool || enriched.length === 0) {
return;
}
if (B.amount) {
const minAmountOut = parseFloat(B?.amount) * (1 - slippage);
setAmountOut(minAmountOut);
}
const liqA = enriched[0].liquidityA;
const liqB = enriched[0].liquidityB;
const supplyRatio = liqA / liqB;
// We need to make sure the order matched the pool's accounts order
const enrichedA = A.mintAddress === enriched[0].mints[0] ? A : B;
const enrichedB = enrichedA.mintAddress === A.mintAddress ? B : A;
const calculatedRatio =
parseFloat(enrichedA.amount) / parseFloat(enrichedB.amount);
// % difference between pool ratio and calculated ratio
setPriceImpact(Math.abs(100 - (calculatedRatio * 100) / supplyRatio));
// 6 decimals without trailing zeros
const lpFeeStr = (parseFloat(A.amount) * LIQUIDITY_PROVIDER_FEE).toFixed(6);
setLpFee(parseFloat(lpFeeStr));
if (priceAccount === B.mintAddress) {
setExchangeRate(parseFloat(B.amount) / parseFloat(A.amount));
} else {
setExchangeRate(parseFloat(A.amount) / parseFloat(B.amount));
}
}, [B, slippage, pool, enriched, priceAccount]);
const handleSwapPriceInfo = () => {
if (priceAccount !== B.mintAddress) {
setPriceAccount(B.mintAddress);
} else {
setPriceAccount(A.mintAddress);
}
};
return !!parseFloat(B.amount) ? (
<div className="pool-card" style={{ width: "initial" }}>
<div className="pool-card-row">
<Text className="pool-card-cell">Price</Text>
<div className="pool-card-cell " title={exchangeRate.toString()}>
<Button
shape="circle"
size="middle"
type="text"
icon={<SwapOutlined />}
onClick={handleSwapPriceInfo}
>
{exchangeRate.toFixed(6)}&nbsp;
{priceAccount === B.mintAddress ? B.name : A.name} per&nbsp;
{priceAccount === B.mintAddress ? A.name : B.name}&nbsp;
</Button>
</div>
</div>
<div className="pool-card-row">
<Text className="pool-card-cell">
<Popover
trigger="hover"
content={
<div style={{ width: 300 }}>
You transaction will revert if there is a large, unfavorable
price movement before it is confirmed.
</div>
}
>
Minimum Received <QuestionCircleOutlined />
</Popover>
</Text>
<div className="pool-card-cell " title={amountOut.toString()}>
{amountOut.toFixed(6)} {B.name}
</div>
</div>
<div className="pool-card-row">
<Text className="pool-card-cell">
<Popover
trigger="hover"
content={
<div style={{ width: 300 }}>
The difference between the market price and estimated price due
to trade size.
</div>
}
>
Price Impact <QuestionCircleOutlined />
</Popover>
</Text>
<div
className="pool-card-cell "
title={priceImpact.toString()}
style={{ color: colorWarning(priceImpact) }}
>
{priceImpact < 0.01 ? "< 0.01%" : priceImpact.toFixed(3) + "%"}
</div>
</div>
<div className="pool-card-row">
<Text className="pool-card-cell">
<Popover
trigger="hover"
content={
<div style={{ width: 300 }}>
A portion of each trade ({LIQUIDITY_PROVIDER_FEE * 100}%) goes
to liquidity providers as a protocol incentive.
</div>
}
>
Liquidity Provider Fee <QuestionCircleOutlined />
</Popover>
</Text>
<div className="pool-card-cell " title={lpFee.toString()}>
{lpFee} {A.name}
</div>
</div>
</div>
) : null;
};

View File

@ -0,0 +1,33 @@
.trade-button {
width: 100%;
position: relative;
:first-child {
width: 100%;
position: relative;
}
}
.trade-spinner {
position: absolute;
right: 5px;
}
.swap-button {
border-radius: 2em;
width: 32px;
padding-left: 8px;
}
.input-card {
display: flex;
flex-direction: column;
align-items: center;
margin-bottom: 10px;
}
.trade-address-info-button {
position: absolute;
right: 0;
top: 0;
}

630
ui/src/context/market.tsx Normal file
View File

@ -0,0 +1,630 @@
import React, { useCallback, useContext, useEffect, useState } from "react";
import { POOLS_WITH_AIRDROP } from "./../models/airdrops";
import { MINT_TO_MARKET } from "./../models/marketOverrides";
import {
convert,
getPoolName,
getTokenName,
KnownTokenMap,
STABLE_COINS,
} from "./../utils/utils";
import { useConnectionConfig } from "./../utils/connection";
import {
cache,
getMultipleAccounts,
MintParser,
ParsedAccountBase,
useCachedPool,
} from "./../utils/accounts";
import { Market, MARKETS, Orderbook, TOKEN_MINTS } from "@project-serum/serum";
import { AccountInfo, Connection, PublicKey } from "@solana/web3.js";
import { useMemo } from "react";
import { PoolInfo } from "../models";
import { EventEmitter } from "./../utils/eventEmitter";
import { LIQUIDITY_PROVIDER_FEE, SERUM_FEE } from "../utils/pools";
interface RecentPoolData {
pool_identifier: string;
volume24hA: number;
}
export interface MarketsContextState {
midPriceInUSD: (mint: string) => number;
marketEmitter: EventEmitter;
accountsToObserve: Map<string, number>;
marketByMint: Map<string, SerumMarket>;
subscribeToMarket: (mint: string) => () => void;
dailyVolume: Map<string, RecentPoolData>;
}
const INITAL_LIQUIDITY_DATE = new Date("2020-10-27");
const REFRESH_INTERVAL = 30_000;
const BONFIDA_POOL_INTERVAL = 30 * 60_000; // 30 min
const MarketsContext = React.createContext<MarketsContextState | null>(null);
const marketEmitter = new EventEmitter();
export function MarketProvider({ children = null as any }) {
const { endpoint } = useConnectionConfig();
const { pools } = useCachedPool();
const accountsToObserve = useMemo(() => new Map<string, number>(), []);
const [dailyVolume, setDailyVolume] = useState<Map<string, RecentPoolData>>(
new Map()
);
const connection = useMemo(() => new Connection(endpoint, "recent"), [
endpoint,
]);
const marketByMint = useMemo(() => {
return [
...new Set(pools.map((p) => p.pubkeys.holdingMints).flat()).values(),
].reduce((acc, key) => {
const mintAddress = key.toBase58();
const SERUM_TOKEN = TOKEN_MINTS.find(
(a) => a.address.toBase58() === mintAddress
);
const marketAddress = MINT_TO_MARKET[mintAddress];
const marketName = `${SERUM_TOKEN?.name}/USDC`;
const marketInfo = MARKETS.find(
(m) => m.name === marketName || m.address.toBase58() === marketAddress
);
if (marketInfo) {
acc.set(mintAddress, {
marketInfo,
});
}
return acc;
}, new Map<string, SerumMarket>()) as Map<string, SerumMarket>;
}, [pools]);
useEffect(() => {
let timer = 0;
let bonfidaTimer = 0;
const updateData = async () => {
await refreshAccounts(connection, [...accountsToObserve.keys()]);
marketEmitter.raiseMarketUpdated(new Set([...marketByMint.keys()]));
timer = window.setTimeout(() => updateData(), REFRESH_INTERVAL);
};
const bonfidaQuery = async () => {
try {
const resp = await window.fetch(
"https://serum-api.bonfida.com/pools-recent"
);
const data = await resp.json();
const map = (data?.data as RecentPoolData[]).reduce((acc, item) => {
acc.set(item.pool_identifier, item);
return acc;
}, new Map<string, RecentPoolData>());
setDailyVolume(map);
} catch {
// ignore
}
bonfidaTimer = window.setTimeout(
() => bonfidaQuery(),
BONFIDA_POOL_INTERVAL
);
};
const initalQuery = async () => {
const reverseSerumMarketCache = new Map<string, string>();
[...marketByMint.keys()].forEach((mint) => {
const m = marketByMint.get(mint);
if (m) {
reverseSerumMarketCache.set(m.marketInfo.address.toBase58(), mint);
}
});
const allMarkets = [...marketByMint.values()].map((m) => {
return m.marketInfo.address.toBase58();
});
await getMultipleAccounts(
connection,
// only query for markets that are not in cahce
allMarkets.filter((a) => cache.get(a) === undefined),
"single"
).then(({ keys, array }) => {
allMarkets.forEach(() => {});
return array.map((item, index) => {
const marketAddress = keys[index];
const mintAddress = reverseSerumMarketCache.get(marketAddress);
if (mintAddress) {
const market = marketByMint.get(mintAddress);
if (market) {
const programId = market.marketInfo.programId;
const id = market.marketInfo.address;
cache.add(id, item, (id, acc) => {
const decoded = Market.getLayout(programId).decode(acc.data);
const details = {
pubkey: id,
account: {
...acc,
},
info: decoded,
} as ParsedAccountBase;
cache.registerParser(details.info.baseMint, MintParser);
cache.registerParser(details.info.quoteMint, MintParser);
cache.registerParser(details.info.bids, OrderBookParser);
cache.registerParser(details.info.asks, OrderBookParser);
return details;
});
}
}
return item;
});
});
const toQuery = new Set<string>();
allMarkets.forEach((m) => {
const market = cache.get(m);
if (!market) {
return;
}
const decoded = market;
if (!cache.get(decoded.info.baseMint)) {
toQuery.add(decoded.info.baseMint.toBase58());
}
if (!cache.get(decoded.info.baseMint)) {
toQuery.add(decoded.info.quoteMint.toBase58());
}
toQuery.add(decoded.info.bids.toBase58());
toQuery.add(decoded.info.asks.toBase58());
// TODO: only update when someone listnes to it
});
await refreshAccounts(connection, [...toQuery.keys()]);
marketEmitter.raiseMarketUpdated(new Set([...marketByMint.keys()]));
// start update loop
updateData();
bonfidaQuery();
};
initalQuery();
return () => {
window.clearTimeout(bonfidaTimer);
window.clearTimeout(timer);
};
}, [pools, marketByMint, accountsToObserve, connection]);
const midPriceInUSD = useCallback(
(mintAddress: string) => {
return getMidPrice(
marketByMint.get(mintAddress)?.marketInfo.address.toBase58(),
mintAddress
);
},
[marketByMint]
);
const subscribeToMarket = useCallback(
(mintAddress: string) => {
const info = marketByMint.get(mintAddress);
const market = cache.get(info?.marketInfo.address.toBase58() || "");
if (!market) {
return () => {};
}
// TODO: get recent volume
const bid = market.info.bids.toBase58();
const ask = market.info.asks.toBase58();
accountsToObserve.set(bid, (accountsToObserve.get(bid) || 0) + 1);
accountsToObserve.set(ask, (accountsToObserve.get(ask) || 0) + 1);
// TODO: add event queue to query for last trade
return () => {
accountsToObserve.set(bid, (accountsToObserve.get(bid) || 0) - 1);
accountsToObserve.set(ask, (accountsToObserve.get(ask) || 0) - 1);
// cleanup
[...accountsToObserve.keys()].forEach((key) => {
if ((accountsToObserve.get(key) || 0) <= 0) {
accountsToObserve.delete(key);
}
});
};
},
[marketByMint, accountsToObserve]
);
return (
<MarketsContext.Provider
value={{
midPriceInUSD,
marketEmitter,
accountsToObserve,
marketByMint,
subscribeToMarket,
dailyVolume: dailyVolume,
}}
>
{children}
</MarketsContext.Provider>
);
}
export const useMarkets = () => {
const context = useContext(MarketsContext);
return context as MarketsContextState;
};
export const useMidPriceInUSD = (mint: string) => {
const { midPriceInUSD, subscribeToMarket, marketEmitter } = useContext(
MarketsContext
) as MarketsContextState;
const [price, setPrice] = useState<number>(0);
useEffect(() => {
let subscription = subscribeToMarket(mint);
const update = () => {
if (midPriceInUSD) {
setPrice(midPriceInUSD(mint));
}
};
update();
const dispose = marketEmitter.onMarket(update);
return () => {
subscription();
dispose();
};
}, [midPriceInUSD, mint, marketEmitter, subscribeToMarket]);
return { price, isBase: price === 1.0 };
};
export const useEnrichedPools = (pools: PoolInfo[]) => {
const context = useContext(MarketsContext);
const { tokenMap } = useConnectionConfig();
const [enriched, setEnriched] = useState<any[]>([]);
const subscribeToMarket = context?.subscribeToMarket;
const marketEmitter = context?.marketEmitter;
const marketsByMint = context?.marketByMint;
const dailyVolume = context?.dailyVolume;
useEffect(() => {
if (!marketEmitter || !subscribeToMarket) {
return;
}
const mints = [...new Set([...marketsByMint?.keys()]).keys()];
const subscriptions = mints.map((m) => subscribeToMarket(m));
const update = () => {
setEnriched(
createEnrichedPools(pools, marketsByMint, dailyVolume, tokenMap)
);
};
const dispose = marketEmitter.onMarket(update);
update();
return () => {
dispose && dispose();
subscriptions.forEach((dispose) => dispose && dispose());
};
}, [
tokenMap,
pools,
dailyVolume,
subscribeToMarket,
marketEmitter,
marketsByMint,
]);
return enriched;
};
// TODO:
// 1. useEnrichedPools
// combines market and pools and user info
// 2. ADD useMidPrice with event to refresh price
// that could subscribe to multiple markets and trigger refresh of those markets only when there is active subscription
function createEnrichedPools(
pools: PoolInfo[],
marketByMint: Map<string, SerumMarket> | undefined,
poolData: Map<string, RecentPoolData> | undefined,
tokenMap: KnownTokenMap
) {
const TODAY = new Date();
if (!marketByMint) {
return [];
}
const result = pools
.filter((p) => p.pubkeys.holdingMints && p.pubkeys.holdingMints.length > 1)
.map((p, index) => {
const mints = (p.pubkeys.holdingMints || [])
.map((a) => a.toBase58())
.sort();
const indexA = mints[0] === p.pubkeys.holdingMints[0]?.toBase58() ? 0 : 1;
const indexB = indexA === 0 ? 1 : 0;
const accountA = cache.getAccount(p.pubkeys.holdingAccounts[indexA]);
const mintA = cache.getMint(mints[0]);
const accountB = cache.getAccount(p.pubkeys.holdingAccounts[indexB]);
const mintB = cache.getMint(mints[1]);
const baseMid = getMidPrice(
marketByMint.get(mints[0])?.marketInfo.address.toBase58() || "",
mints[0]
);
const baseReserveUSD = baseMid * convert(accountA, mintA);
const quote = getMidPrice(
marketByMint.get(mints[1])?.marketInfo.address.toBase58() || "",
mints[1]
);
const quoteReserveUSD = quote * convert(accountB, mintB);
const poolMint = cache.getMint(p.pubkeys.mint);
if (poolMint?.supply.eqn(0)) {
return undefined;
}
let airdropYield = calculateAirdropYield(
p,
marketByMint,
baseReserveUSD,
quoteReserveUSD
);
let volume = 0;
let volume24h =
baseMid * (poolData?.get(p.pubkeys.mint.toBase58())?.volume24hA || 0);
let fees24h = volume24h * (LIQUIDITY_PROVIDER_FEE - SERUM_FEE);
let fees = 0;
let apy = airdropYield;
let apy24h = airdropYield;
if (p.pubkeys.feeAccount) {
const feeAccount = cache.getAccount(p.pubkeys.feeAccount);
if (
poolMint &&
feeAccount &&
feeAccount.info.mint.toBase58() === p.pubkeys.mint.toBase58()
) {
const feeBalance = feeAccount?.info.amount.toNumber();
const supply = poolMint?.supply.toNumber();
const ownedPct = feeBalance / supply;
const poolOwnerFees =
ownedPct * baseReserveUSD + ownedPct * quoteReserveUSD;
volume = poolOwnerFees / 0.0004;
fees = volume * LIQUIDITY_PROVIDER_FEE;
if (fees !== 0) {
const baseVolume = (ownedPct * baseReserveUSD) / 0.0004;
const quoteVolume = (ownedPct * quoteReserveUSD) / 0.0004;
// Aproximation not true for all pools we need to fine a better way
const daysSinceInception = Math.floor(
(TODAY.getTime() - INITAL_LIQUIDITY_DATE.getTime()) /
(24 * 3600 * 1000)
);
const apy0 =
parseFloat(
((baseVolume / daysSinceInception) * LIQUIDITY_PROVIDER_FEE * 356) as any
) / baseReserveUSD;
const apy1 =
parseFloat(
((quoteVolume / daysSinceInception) * LIQUIDITY_PROVIDER_FEE * 356) as any
) / quoteReserveUSD;
apy = apy + Math.max(apy0, apy1);
const apy24h0 =
parseFloat((volume24h * LIQUIDITY_PROVIDER_FEE * 356) as any) / baseReserveUSD;
apy24h = apy24h + apy24h0;
}
}
}
const lpMint = cache.getMint(p.pubkeys.mint);
const name = getPoolName(tokenMap, p);
const link = `#/?pair=${getPoolName(tokenMap, p, false).replace(
"/",
"-"
)}`;
return {
key: p.pubkeys.account.toBase58(),
id: index,
name,
names: mints.map((m) => getTokenName(tokenMap, m)),
address: p.pubkeys.mint.toBase58(),
link,
mints,
liquidityA: convert(accountA, mintA),
liquidityAinUsd: baseReserveUSD,
liquidityB: convert(accountB, mintB),
liquidityBinUsd: quoteReserveUSD,
supply:
lpMint &&
(
lpMint?.supply.toNumber() / Math.pow(10, lpMint?.decimals || 0)
).toFixed(9),
fees,
fees24h,
liquidity: baseReserveUSD + quoteReserveUSD,
volume,
volume24h,
apy: Number.isFinite(apy) ? apy : 0,
apy24h: Number.isFinite(apy24h) ? apy24h : 0,
map: poolData,
extra: poolData?.get(p.pubkeys.account.toBase58()),
raw: p,
};
})
.filter((p) => p !== undefined);
return result;
}
function calculateAirdropYield(
p: PoolInfo,
marketByMint: Map<string, SerumMarket>,
baseReserveUSD: number,
quoteReserveUSD: number
) {
let airdropYield = 0;
let poolWithAirdrop = POOLS_WITH_AIRDROP.find((drop) =>
drop.pool.equals(p.pubkeys.mint)
);
if (poolWithAirdrop) {
airdropYield = poolWithAirdrop.airdrops.reduce((acc, item) => {
const market = marketByMint.get(item.mint.toBase58())?.marketInfo.address;
if (market) {
const midPrice = getMidPrice(market?.toBase58(), item.mint.toBase58());
acc =
acc +
// airdrop yield
((item.amount * midPrice) / (baseReserveUSD + quoteReserveUSD)) *
(365 / 30);
}
return acc;
}, 0);
}
return airdropYield;
}
const OrderBookParser = (id: PublicKey, acc: AccountInfo<Buffer>) => {
const decoded = Orderbook.LAYOUT.decode(acc.data);
const details = {
pubkey: id,
account: {
...acc,
},
info: decoded,
} as ParsedAccountBase;
return details;
};
const getMidPrice = (marketAddress?: string, mintAddress?: string) => {
const SERUM_TOKEN = TOKEN_MINTS.find(
(a) => a.address.toBase58() === mintAddress
);
if (STABLE_COINS.has(SERUM_TOKEN?.name || "")) {
return 1.0;
}
if (!marketAddress) {
return 0.0;
}
const marketInfo = cache.get(marketAddress);
if (!marketInfo) {
return 0.0;
}
const decodedMarket = marketInfo.info;
const baseMintDecimals =
cache.get(decodedMarket.baseMint)?.info.decimals || 0;
const quoteMintDecimals =
cache.get(decodedMarket.quoteMint)?.info.decimals || 0;
const market = new Market(
decodedMarket,
baseMintDecimals,
quoteMintDecimals,
undefined,
decodedMarket.programId
);
const bids = cache.get(decodedMarket.bids)?.info;
const asks = cache.get(decodedMarket.asks)?.info;
if (bids && asks) {
const bidsBook = new Orderbook(market, bids.accountFlags, bids.slab);
const asksBook = new Orderbook(market, asks.accountFlags, asks.slab);
const bestBid = bidsBook.getL2(1);
const bestAsk = asksBook.getL2(1);
if (bestBid.length > 0 && bestAsk.length > 0) {
return (bestBid[0][0] + bestAsk[0][0]) / 2.0;
}
}
return 0;
};
const refreshAccounts = async (connection: Connection, keys: string[]) => {
if (keys.length === 0) {
return [];
}
return getMultipleAccounts(connection, keys, "single").then(
({ keys, array }) => {
return array.map((item, index) => {
const address = keys[index];
return cache.add(new PublicKey(address), item);
});
}
);
};
interface SerumMarket {
marketInfo: {
address: PublicKey;
name: string;
programId: PublicKey;
deprecated: boolean;
};
// 1st query
marketAccount?: AccountInfo<Buffer>;
// 2nd query
mintBase?: AccountInfo<Buffer>;
mintQuote?: AccountInfo<Buffer>;
bidAccount?: AccountInfo<Buffer>;
askAccount?: AccountInfo<Buffer>;
eventQueue?: AccountInfo<Buffer>;
swap?: {
dailyVolume: number;
};
midPrice?: (mint?: PublicKey) => number;
}

23
ui/src/contract_keys.json Normal file
View File

@ -0,0 +1,23 @@
{
"contract_name": "DEMSEN",
"details": "Resolution: The number of U.S. senators who were elected with a ballot-listed or otherwise identifiable affiliation with, or who have publicly stated an intention to caucus with the Democratic party shall be greater than or equal to 50 if the Vice President is affiliated with the Democratic party or greater than 50 otherwise. The YES tokens can be redeemed for 1 USDC if the resolution is true at 2021-02-01 00:00:00 UTC and the NO tokens can be redeemed for 1 USDC otherwise. This contract will be resolved in the same way as the Democratic contract on PredictIt at this URL: https://www.predictit.org/markets/detail/4366",
"omega_contract_pk": "B5ZoQkBsxSvtuRRmtqbaXpnFjaiTXhiK528tZsA66jAm",
"omega_program_id": "13vjKM1HFhup2axciLDyx6QZ67H9ki1fcs828DKnGa53",
"oracle_pk": "FJpmfVUmd75kVieMjBLixdk5611xvXVUNadhcSbhE4Hm",
"outcomes": [
{
"icon": "https://az620379.vo.msecnd.net/images/Contracts/small_29b55b5a-6faf-4041-8b21-ab27421d0ade.png",
"mint_pk": "2qn4HeUksk5k9jbhkTQzVWXw8kfGcaYVJhkza9ue5k65",
"name": "YES"
},
{
"icon": "https://az620379.vo.msecnd.net/images/Contracts/small_77aea45d-8c93-46d6-b338-43a6af0ba8e1.png",
"mint_pk": "6YwZzhRVhPD5UzJpKbzU24ym891FFaD2hdzBVM1A4zXB",
"name": "NO"
}
],
"quote_mint_pk": "Fq939Y5hycK62ZGwBjftLY2VyxqAQ8f1MxRqBMdAaBS7",
"quote_vault_pk": "DxEVCG5eNhKV5Qrq7AS1CkqZh5W3HrbH9kCFhoybX9Z8",
"signer_nonce": 1,
"signer_pk": "wcKrWmAiZ5qJz9ELPCUAhvNzdfcb55Po29r5fS3e9Tf"
}

13
ui/src/index.css Normal file
View File

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

16
ui/src/index.tsx Normal file
View File

@ -0,0 +1,16 @@
import React from "react";
import ReactDOM from "react-dom";
import "./index.css";
import App from "./App";
import * as serviceWorker from "./serviceWorker";
ReactDOM.render(
<React.StrictMode>
<App />
</React.StrictMode>,
document.getElementById("root")
);
// If you want your app to work offline and load faster, you can change
// unregister() to register() below. Note this comes with some pitfalls.
// Learn more about service workers: https://bit.ly/CRA-PWA
serviceWorker.unregister();

9
ui/src/models/account.ts Normal file
View File

@ -0,0 +1,9 @@
import { AccountInfo, PublicKey } from "@solana/web3.js";
import { AccountInfo as TokenAccountInfo } from "@solana/spl-token";
export interface TokenAccount {
pubkey: PublicKey;
account: AccountInfo<Buffer>;
info: TokenAccountInfo;
}

11
ui/src/models/airdrops.ts Normal file
View File

@ -0,0 +1,11 @@
import { PublicKey } from "@solana/web3.js";
interface PoolAirdrop {
pool: PublicKey;
airdrops: {
mint: PublicKey;
amount: number;
}[];
}
export const POOLS_WITH_AIRDROP: PoolAirdrop[] = [];

3
ui/src/models/index.ts Normal file
View File

@ -0,0 +1,3 @@
export * from "./pool";
export * from "./account";
export * from "./tokenSwap";

View File

@ -0,0 +1,2 @@
// use to override serum market to use specifc mint
export const MINT_TO_MARKET: { [key: string]: string } = {};

31
ui/src/models/pool.ts Normal file
View File

@ -0,0 +1,31 @@
import { PublicKey } from "@solana/web3.js";
import { TokenAccount } from "./account";
export interface PoolInfo {
pubkeys: {
program: PublicKey;
account: PublicKey;
holdingAccounts: PublicKey[];
holdingMints: PublicKey[];
mint: PublicKey;
feeAccount?: PublicKey;
};
legacy: boolean;
raw: any;
}
export interface LiquidityComponent {
amount: number;
account?: TokenAccount;
mintAddress: string;
}
export interface PoolConfig {
curveType: 0 | 1;
tradeFeeNumerator: number;
tradeFeeDenominator: number;
ownerTradeFeeNumerator: number;
ownerTradeFeeDenominator: number;
ownerWithdrawFeeNumerator: number;
ownerWithdrawFeeDenominator: number;
}

281
ui/src/models/tokenSwap.ts Normal file
View File

@ -0,0 +1,281 @@
import { Numberu64 } from "@solana/spl-token-swap";
import { PublicKey, Account, TransactionInstruction } from "@solana/web3.js";
import * as BufferLayout from "buffer-layout";
export { TokenSwap } from "@solana/spl-token-swap";
/**
* Layout for a public key
*/
export const publicKey = (property: string = "publicKey"): Object => {
return BufferLayout.blob(32, property);
};
/**
* Layout for a 64bit unsigned value
*/
export const uint64 = (property: string = "uint64"): Object => {
return BufferLayout.blob(8, property);
};
export const TokenSwapLayoutLegacyV0 = BufferLayout.struct([
BufferLayout.u8("isInitialized"),
BufferLayout.u8("nonce"),
publicKey("tokenAccountA"),
publicKey("tokenAccountB"),
publicKey("tokenPool"),
uint64("feesNumerator"),
uint64("feesDenominator"),
]);
export const TokenSwapLayout: typeof BufferLayout.Structure = BufferLayout.struct(
[
BufferLayout.u8("isInitialized"),
BufferLayout.u8("nonce"),
publicKey("tokenProgramId"),
publicKey("tokenAccountA"),
publicKey("tokenAccountB"),
publicKey("tokenPool"),
publicKey("mintA"),
publicKey("mintB"),
publicKey("feeAccount"),
BufferLayout.u8("curveType"),
uint64("tradeFeeNumerator"),
uint64("tradeFeeDenominator"),
uint64("ownerTradeFeeNumerator"),
uint64("ownerTradeFeeDenominator"),
uint64("ownerWithdrawFeeNumerator"),
uint64("ownerWithdrawFeeDenominator"),
BufferLayout.blob(16, "padding"),
]
);
export const createInitSwapInstruction = (
tokenSwapAccount: Account,
authority: PublicKey,
tokenAccountA: PublicKey,
tokenAccountB: PublicKey,
tokenPool: PublicKey,
feeAccount: PublicKey,
tokenAccountPool: PublicKey,
tokenProgramId: PublicKey,
swapProgramId: PublicKey,
nonce: number,
curveType: number,
tradeFeeNumerator: number,
tradeFeeDenominator: number,
ownerTradeFeeNumerator: number,
ownerTradeFeeDenominator: number,
ownerWithdrawFeeNumerator: number,
ownerWithdrawFeeDenominator: number
): TransactionInstruction => {
const keys = [
{ pubkey: tokenSwapAccount.publicKey, isSigner: false, isWritable: true },
{ pubkey: authority, isSigner: false, isWritable: false },
{ pubkey: tokenAccountA, isSigner: false, isWritable: false },
{ pubkey: tokenAccountB, isSigner: false, isWritable: false },
{ pubkey: tokenPool, isSigner: false, isWritable: true },
{ pubkey: feeAccount, isSigner: false, isWritable: false },
{ pubkey: tokenAccountPool, isSigner: false, isWritable: true },
{ pubkey: tokenProgramId, isSigner: false, isWritable: false },
];
const commandDataLayout = BufferLayout.struct([
BufferLayout.u8("instruction"),
BufferLayout.u8("nonce"),
BufferLayout.u8("curveType"),
BufferLayout.nu64("tradeFeeNumerator"),
BufferLayout.nu64("tradeFeeDenominator"),
BufferLayout.nu64("ownerTradeFeeNumerator"),
BufferLayout.nu64("ownerTradeFeeDenominator"),
BufferLayout.nu64("ownerWithdrawFeeNumerator"),
BufferLayout.nu64("ownerWithdrawFeeDenominator"),
BufferLayout.blob(16, "padding"),
]);
let data = Buffer.alloc(1024);
{
const encodeLength = commandDataLayout.encode(
{
instruction: 0, // InitializeSwap instruction
nonce,
curveType,
tradeFeeNumerator,
tradeFeeDenominator,
ownerTradeFeeNumerator,
ownerTradeFeeDenominator,
ownerWithdrawFeeNumerator,
ownerWithdrawFeeDenominator,
},
data
);
data = data.slice(0, encodeLength);
}
return new TransactionInstruction({
keys,
programId: swapProgramId,
data,
});
};
export const depositInstruction = (
tokenSwap: PublicKey,
authority: PublicKey,
sourceA: PublicKey,
sourceB: PublicKey,
intoA: PublicKey,
intoB: PublicKey,
poolToken: PublicKey,
poolAccount: PublicKey,
swapProgramId: PublicKey,
tokenProgramId: PublicKey,
poolTokenAmount: number | Numberu64,
maximumTokenA: number | Numberu64,
maximumTokenB: number | Numberu64
): TransactionInstruction => {
const dataLayout = BufferLayout.struct([
BufferLayout.u8("instruction"),
uint64("poolTokenAmount"),
uint64("maximumTokenA"),
uint64("maximumTokenB"),
]);
const data = Buffer.alloc(dataLayout.span);
dataLayout.encode(
{
instruction: 2, // Deposit instruction
poolTokenAmount: new Numberu64(poolTokenAmount).toBuffer(),
maximumTokenA: new Numberu64(maximumTokenA).toBuffer(),
maximumTokenB: new Numberu64(maximumTokenB).toBuffer(),
},
data
);
const keys = [
{ pubkey: tokenSwap, isSigner: false, isWritable: false },
{ pubkey: authority, isSigner: false, isWritable: false },
{ pubkey: sourceA, isSigner: false, isWritable: true },
{ pubkey: sourceB, isSigner: false, isWritable: true },
{ pubkey: intoA, isSigner: false, isWritable: true },
{ pubkey: intoB, isSigner: false, isWritable: true },
{ pubkey: poolToken, isSigner: false, isWritable: true },
{ pubkey: poolAccount, isSigner: false, isWritable: true },
{ pubkey: tokenProgramId, isSigner: false, isWritable: false },
];
return new TransactionInstruction({
keys,
programId: swapProgramId,
data,
});
};
export const withdrawInstruction = (
tokenSwap: PublicKey,
authority: PublicKey,
poolMint: PublicKey,
feeAccount: PublicKey | undefined,
sourcePoolAccount: PublicKey,
fromA: PublicKey,
fromB: PublicKey,
userAccountA: PublicKey,
userAccountB: PublicKey,
swapProgramId: PublicKey,
tokenProgramId: PublicKey,
poolTokenAmount: number | Numberu64,
minimumTokenA: number | Numberu64,
minimumTokenB: number | Numberu64
): TransactionInstruction => {
const dataLayout = BufferLayout.struct([
BufferLayout.u8("instruction"),
uint64("poolTokenAmount"),
uint64("minimumTokenA"),
uint64("minimumTokenB"),
]);
const data = Buffer.alloc(dataLayout.span);
dataLayout.encode(
{
instruction: 3, // Withdraw instruction
poolTokenAmount: new Numberu64(poolTokenAmount).toBuffer(),
minimumTokenA: new Numberu64(minimumTokenA).toBuffer(),
minimumTokenB: new Numberu64(minimumTokenB).toBuffer(),
},
data
);
const keys = [
{ pubkey: tokenSwap, isSigner: false, isWritable: false },
{ pubkey: authority, isSigner: false, isWritable: false },
{ pubkey: poolMint, isSigner: false, isWritable: true },
{ pubkey: sourcePoolAccount, isSigner: false, isWritable: true },
{ pubkey: fromA, isSigner: false, isWritable: true },
{ pubkey: fromB, isSigner: false, isWritable: true },
{ pubkey: userAccountA, isSigner: false, isWritable: true },
{ pubkey: userAccountB, isSigner: false, isWritable: true },
];
if (feeAccount) {
keys.push({ pubkey: feeAccount, isSigner: false, isWritable: true });
}
keys.push({ pubkey: tokenProgramId, isSigner: false, isWritable: false });
return new TransactionInstruction({
keys,
programId: swapProgramId,
data,
});
};
export const swapInstruction = (
tokenSwap: PublicKey,
authority: PublicKey,
userSource: PublicKey,
poolSource: PublicKey,
poolDestination: PublicKey,
userDestination: PublicKey,
poolMint: PublicKey,
feeAccount: PublicKey,
swapProgramId: PublicKey,
tokenProgramId: PublicKey,
amountIn: number | Numberu64,
minimumAmountOut: number | Numberu64,
programOwner?: PublicKey
): TransactionInstruction => {
const dataLayout = BufferLayout.struct([
BufferLayout.u8("instruction"),
uint64("amountIn"),
uint64("minimumAmountOut"),
]);
const keys = [
{ pubkey: tokenSwap, isSigner: false, isWritable: false },
{ pubkey: authority, isSigner: false, isWritable: false },
{ pubkey: userSource, isSigner: false, isWritable: true },
{ pubkey: poolSource, isSigner: false, isWritable: true },
{ pubkey: poolDestination, isSigner: false, isWritable: true },
{ pubkey: userDestination, isSigner: false, isWritable: true },
{ pubkey: poolMint, isSigner: false, isWritable: true },
{ pubkey: feeAccount, isSigner: false, isWritable: true },
{ pubkey: tokenProgramId, isSigner: false, isWritable: false },
];
// optional depending on the build of token-swap program
if (programOwner) {
keys.push({ pubkey: programOwner, isSigner: false, isWritable: true });
}
const data = Buffer.alloc(dataLayout.span);
dataLayout.encode(
{
instruction: 1, // Swap instruction
amountIn: new Numberu64(amountIn).toBuffer(),
minimumAmountOut: new Numberu64(minimumAmountOut).toBuffer(),
},
data
);
return new TransactionInstruction({
keys,
programId: swapProgramId,
data,
});
};

1
ui/src/react-app-env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="react-scripts" />

32
ui/src/routes.tsx Normal file
View File

@ -0,0 +1,32 @@
import { HashRouter, Route } from "react-router-dom";
import React from "react";
import { BetView } from "./components/bet";
import { ExchangeView } from "./components/exchange";
import { PoolOverview } from "./components/pool/view";
import { RedeemView } from "./components/redeem";
import { WalletProvider } from "./utils/wallet";
import { ConnectionProvider } from "./utils/connection";
import { AccountsProvider } from "./utils/accounts";
import { MarketProvider } from "./context/market";
export function Routes() {
return (
<>
<HashRouter basename={"/"}>
<ConnectionProvider>
<WalletProvider>
<AccountsProvider>
<MarketProvider>
<Route exact path="/" component={BetView} />
<Route exact path="/exchange" component={ExchangeView} />
<Route exact path="/redeem" component={RedeemView} />
<Route exact path="/pool" component={PoolOverview} />
</MarketProvider>
</AccountsProvider>
</WalletProvider>
</ConnectionProvider>
</HashRouter>
</>
);
}

146
ui/src/serviceWorker.ts Normal file
View File

@ -0,0 +1,146 @@
// This optional code is used to register a service worker.
// register() is not called by default.
// This lets the app load faster on subsequent visits in production, and gives
// it offline capabilities. However, it also means that developers (and users)
// will only see deployed updates on subsequent visits to a page, after all the
// existing tabs open on the page have been closed, since previously cached
// resources are updated in the background.
// To learn more about the benefits of this model and instructions on how to
// opt-in, read https://bit.ly/CRA-PWA
const isLocalhost = Boolean(
window.location.hostname === "localhost" ||
// [::1] is the IPv6 localhost address.
window.location.hostname === "[::1]" ||
// 127.0.0.0/8 are considered localhost for IPv4.
window.location.hostname.match(
/^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/
)
);
type Config = {
onSuccess?: (registration: ServiceWorkerRegistration) => void;
onUpdate?: (registration: ServiceWorkerRegistration) => void;
};
export function register(config?: Config) {
if (process.env.NODE_ENV === "production" && "serviceWorker" in navigator) {
// The URL constructor is available in all browsers that support SW.
const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href);
if (publicUrl.origin !== window.location.origin) {
// Our service worker won't work if PUBLIC_URL is on a different origin
// from what our page is served on. This might happen if a CDN is used to
// serve assets; see https://github.com/facebook/create-react-app/issues/2374
return;
}
window.addEventListener("load", () => {
const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`;
if (isLocalhost) {
// This is running on localhost. Let's check if a service worker still exists or not.
checkValidServiceWorker(swUrl, config);
// Add some additional logging to localhost, pointing developers to the
// service worker/PWA documentation.
navigator.serviceWorker.ready.then(() => {
console.log(
"This web app is being served cache-first by a service " +
"worker. To learn more, visit https://bit.ly/CRA-PWA"
);
});
} else {
// Is not localhost. Just register service worker
registerValidSW(swUrl, config);
}
});
}
}
function registerValidSW(swUrl: string, config?: Config) {
navigator.serviceWorker
.register(swUrl)
.then((registration) => {
registration.onupdatefound = () => {
const installingWorker = registration.installing;
if (installingWorker == null) {
return;
}
installingWorker.onstatechange = () => {
if (installingWorker.state === "installed") {
if (navigator.serviceWorker.controller) {
// At this point, the updated precached content has been fetched,
// but the previous service worker will still serve the older
// content until all client tabs are closed.
console.log(
"New content is available and will be used when all " +
"tabs for this page are closed. See https://bit.ly/CRA-PWA."
);
// Execute callback
if (config && config.onUpdate) {
config.onUpdate(registration);
}
} else {
// At this point, everything has been precached.
// It's the perfect time to display a
// "Content is cached for offline use." message.
console.log("Content is cached for offline use.");
// Execute callback
if (config && config.onSuccess) {
config.onSuccess(registration);
}
}
}
};
};
})
.catch((error) => {
console.error("Error during service worker registration:", error);
});
}
function checkValidServiceWorker(swUrl: string, config?: Config) {
// Check if the service worker can be found. If it can't reload the page.
fetch(swUrl, {
headers: { "Service-Worker": "script" },
})
.then((response) => {
// Ensure service worker exists, and that we really are getting a JS file.
const contentType = response.headers.get("content-type");
if (
response.status === 404 ||
(contentType != null && contentType.indexOf("javascript") === -1)
) {
// No service worker found. Probably a different app. Reload the page.
navigator.serviceWorker.ready.then((registration) => {
registration.unregister().then(() => {
window.location.reload();
});
});
} else {
// Service worker found. Proceed as normal.
registerValidSW(swUrl, config);
}
})
.catch(() => {
console.log(
"No internet connection found. App is running in offline mode."
);
});
}
export function unregister() {
if ("serviceWorker" in navigator) {
navigator.serviceWorker.ready
.then((registration) => {
registration.unregister();
})
.catch((error) => {
console.error(error.message);
});
}
}

5
ui/src/setupTests.ts Normal file
View File

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

4
ui/src/sol-wallet-adapter.d.ts vendored Normal file
View File

@ -0,0 +1,4 @@
declare module "@project-serum/sol-wallet-adapter" {
const magic: any;
export = magic;
}

705
ui/src/utils/accounts.tsx Normal file
View File

@ -0,0 +1,705 @@
import React, { useCallback, useContext, useEffect, useState } from "react";
import { useConnection } from "./connection";
import { useWallet } from "./wallet";
import { AccountInfo, Connection, PublicKey } from "@solana/web3.js";
import { programIds, SWAP_HOST_FEE_ADDRESS, WRAPPED_SOL_MINT } from "./ids";
import { AccountLayout, u64, MintInfo, MintLayout } from "@solana/spl-token";
import { usePools } from "./pools";
import { TokenAccount, PoolInfo } from "./../models";
import { notify } from "./notifications";
import { chunks } from "./utils";
import { EventEmitter } from "./eventEmitter";
const AccountsContext = React.createContext<any>(null);
const accountEmitter = new EventEmitter();
const pendingMintCalls = new Map<string, Promise<MintInfo>>();
const mintCache = new Map<string, MintInfo>();
const pendingAccountCalls = new Map<string, Promise<TokenAccount>>();
const accountsCache = new Map<string, TokenAccount>();
const pendingCalls = new Map<string, Promise<ParsedAccountBase>>();
const genericCache = new Map<string, ParsedAccountBase>();
const getAccountInfo = async (connection: Connection, pubKey: PublicKey) => {
const info = await connection.getAccountInfo(pubKey);
if (info === null) {
throw new Error("Failed to find mint account");
}
return tokenAccountFactory(pubKey, info);
};
const getMintInfo = async (connection: Connection, pubKey: PublicKey) => {
const info = await connection.getAccountInfo(pubKey);
if (info === null) {
throw new Error("Failed to find mint account");
}
const data = Buffer.from(info.data);
return deserializeMint(data);
};
export interface ParsedAccountBase {
pubkey: PublicKey;
account: AccountInfo<Buffer>;
info: any; // TODO: change to unkown
}
export interface ParsedAccount<T> extends ParsedAccountBase {
info: T;
}
export type AccountParser = (
pubkey: PublicKey,
data: AccountInfo<Buffer>
) => ParsedAccountBase;
export const MintParser = (pubKey: PublicKey, info: AccountInfo<Buffer>) => {
const buffer = Buffer.from(info.data);
const data = deserializeMint(buffer);
const details = {
pubkey: pubKey,
account: {
...info,
},
info: data,
} as ParsedAccountBase;
return details;
};
export const TokenAccountParser = tokenAccountFactory;
export const GenericAccountParser = (
pubKey: PublicKey,
info: AccountInfo<Buffer>
) => {
const buffer = Buffer.from(info.data);
const details = {
pubkey: pubKey,
account: {
...info,
},
info: buffer,
} as ParsedAccountBase;
return details;
};
export const keyToAccountParser = new Map<string, AccountParser>();
export const cache = {
query: async (
connection: Connection,
pubKey: string | PublicKey,
parser?: AccountParser
) => {
let id: PublicKey;
if (typeof pubKey === "string") {
id = new PublicKey(pubKey);
} else {
id = pubKey;
}
const address = id.toBase58();
let account = genericCache.get(address);
if (account) {
return account;
}
let query = pendingCalls.get(address);
if (query) {
return query;
}
query = connection.getAccountInfo(id).then((data) => {
if (!data) {
throw new Error("Account not found");
}
return cache.add(id, data, parser);
}) as Promise<TokenAccount>;
pendingCalls.set(address, query as any);
return query;
},
add: (id: PublicKey, obj: AccountInfo<Buffer>, parser?: AccountParser) => {
const address = id.toBase58();
const deserialize = parser ? parser : keyToAccountParser.get(address);
if (!deserialize) {
throw new Error(
"Deserializer needs to be registered or passed as a parameter"
);
}
cache.registerParser(id, deserialize);
pendingCalls.delete(address);
const account = deserialize(id, obj);
genericCache.set(address, account);
return account;
},
get: (pubKey: string | PublicKey) => {
let key: string;
if (typeof pubKey !== "string") {
key = pubKey.toBase58();
} else {
key = pubKey;
}
return genericCache.get(key);
},
registerParser: (pubkey: PublicKey, parser: AccountParser) => {
keyToAccountParser.set(pubkey.toBase58(), parser);
},
queryAccount: async (connection: Connection, pubKey: string | PublicKey) => {
let id: PublicKey;
if (typeof pubKey === "string") {
id = new PublicKey(pubKey);
} else {
id = pubKey;
}
const address = id.toBase58();
let account = accountsCache.get(address);
if (account) {
return account;
}
let query = pendingAccountCalls.get(address);
if (query) {
return query;
}
query = getAccountInfo(connection, id).then((data) => {
pendingAccountCalls.delete(address);
accountsCache.set(address, data);
return data;
}) as Promise<TokenAccount>;
pendingAccountCalls.set(address, query as any);
return query;
},
addAccount: (pubKey: PublicKey, obj: AccountInfo<Buffer>) => {
const account = tokenAccountFactory(pubKey, obj);
accountsCache.set(account.pubkey.toBase58(), account);
return account;
},
deleteAccount: (pubkey: PublicKey) => {
const id = pubkey?.toBase58();
accountsCache.delete(id);
accountEmitter.raiseAccountUpdated(id);
},
getAccount: (pubKey: string | PublicKey) => {
let key: string;
if (typeof pubKey !== "string") {
key = pubKey.toBase58();
} else {
key = pubKey;
}
return accountsCache.get(key);
},
queryMint: async (connection: Connection, pubKey: string | PublicKey) => {
let id: PublicKey;
if (typeof pubKey === "string") {
id = new PublicKey(pubKey);
} else {
id = pubKey;
}
const address = id.toBase58();
let mint = mintCache.get(address);
if (mint) {
return mint;
}
let query = pendingMintCalls.get(address);
if (query) {
return query;
}
query = getMintInfo(connection, id).then((data) => {
pendingAccountCalls.delete(address);
mintCache.set(address, data);
return data;
}) as Promise<MintInfo>;
pendingAccountCalls.set(address, query as any);
return query;
},
getMint: (pubKey: string | PublicKey) => {
let key: string;
if (typeof pubKey !== "string") {
key = pubKey.toBase58();
} else {
key = pubKey;
}
return mintCache.get(key);
},
addMint: (pubKey: PublicKey, obj: AccountInfo<Buffer>) => {
const mint = deserializeMint(obj.data);
mintCache.set(pubKey.toBase58(), mint);
return mint;
},
};
export const getCachedAccount = (
predicate: (account: TokenAccount) => boolean
) => {
for (const account of accountsCache.values()) {
if (predicate(account)) {
return account as TokenAccount;
}
}
};
function tokenAccountFactory(pubKey: PublicKey, info: AccountInfo<Buffer>) {
const buffer = Buffer.from(info.data);
const data = deserializeAccount(buffer);
const details = {
pubkey: pubKey,
account: {
...info,
},
info: data,
} as TokenAccount;
return details;
}
function wrapNativeAccount(
pubkey: PublicKey,
account?: AccountInfo<Buffer>
): TokenAccount | undefined {
if (!account) {
return undefined;
}
return {
pubkey: pubkey,
account,
info: {
mint: WRAPPED_SOL_MINT,
owner: pubkey,
amount: new u64(account.lamports),
delegate: null,
delegatedAmount: new u64(0),
isInitialized: true,
isFrozen: false,
isNative: true,
rentExemptReserve: null,
closeAuthority: null,
},
};
}
const UseNativeAccount = () => {
const connection = useConnection();
const { wallet } = useWallet();
const [nativeAccount, setNativeAccount] = useState<AccountInfo<Buffer>>();
useEffect(() => {
if (!connection || !wallet?.publicKey) {
return;
}
connection.getAccountInfo(wallet.publicKey).then((acc) => {
if (acc) {
setNativeAccount(acc);
}
});
connection.onAccountChange(wallet.publicKey, (acc) => {
if (acc) {
setNativeAccount(acc);
}
});
}, [setNativeAccount, wallet, wallet.publicKey, connection]);
return { nativeAccount };
};
const PRECACHED_OWNERS = new Set<string>();
const precacheUserTokenAccounts = async (
connection: Connection,
owner?: PublicKey
) => {
if (!owner) {
return;
}
// used for filtering account updates over websocket
PRECACHED_OWNERS.add(owner.toBase58());
// user accounts are update via ws subscription
const accounts = await connection.getTokenAccountsByOwner(owner, {
programId: programIds().token,
});
accounts.value
.map((info) => {
const data = deserializeAccount(info.account.data);
// need to query for mint to get decimals
// TODO: move to web3.js for decoding on the client side... maybe with callback
const details = {
pubkey: info.pubkey,
account: {
...info.account,
},
info: data,
} as TokenAccount;
return details;
})
.forEach((acc) => {
accountsCache.set(acc.pubkey.toBase58(), acc);
});
};
export function AccountsProvider({ children = null as any }) {
const connection = useConnection();
const { wallet, connected } = useWallet();
const [tokenAccounts, setTokenAccounts] = useState<TokenAccount[]>([]);
const [userAccounts, setUserAccounts] = useState<TokenAccount[]>([]);
const { nativeAccount } = UseNativeAccount();
const { pools } = usePools();
const publicKey = wallet?.publicKey;
const selectUserAccounts = useCallback(() => {
return [...accountsCache.values()].filter(
(a) => a.info.owner.toBase58() === publicKey?.toBase58()
);
}, [publicKey]);
useEffect(() => {
setUserAccounts(
[
wrapNativeAccount(publicKey, nativeAccount),
...tokenAccounts,
].filter((a) => a !== undefined) as TokenAccount[]
);
}, [nativeAccount, publicKey, tokenAccounts]);
useEffect(() => {
if (!connection || !publicKey) {
setTokenAccounts([]);
} else {
// cache host accounts to avoid query during swap
precacheUserTokenAccounts(connection, SWAP_HOST_FEE_ADDRESS);
precacheUserTokenAccounts(connection, publicKey).then(() => {
setTokenAccounts(selectUserAccounts());
});
const dispose = accountEmitter.onAccount(() => {
setTokenAccounts(selectUserAccounts());
})
// This can return different types of accounts: token-account, mint, multisig
// TODO: web3.js expose ability to filter. discuss filter syntax
const tokenSubID = connection.onProgramAccountChange(
programIds().token,
(info) => {
// TODO: fix type in web3.js
const id = (info.accountId as unknown) as string;
// TODO: do we need a better way to identify layout (maybe a enum identifing type?)
if (info.accountInfo.data.length === AccountLayout.span) {
const data = deserializeAccount(info.accountInfo.data);
// TODO: move to web3.js for decoding on the client side... maybe with callback
const details = {
pubkey: new PublicKey((info.accountId as unknown) as string),
account: {
...info.accountInfo,
},
info: data,
} as TokenAccount;
if (
PRECACHED_OWNERS.has(details.info.owner.toBase58()) ||
accountsCache.has(id)
) {
accountsCache.set(id, details);
accountEmitter.raiseAccountUpdated(id);
}
} else if (info.accountInfo.data.length === MintLayout.span) {
if (mintCache.has(id)) {
const data = Buffer.from(info.accountInfo.data);
const mint = deserializeMint(data);
mintCache.set(id, mint);
}
accountEmitter.raiseAccountUpdated(id);
}
if (genericCache.has(id)) {
cache.add(new PublicKey(id), info.accountInfo);
}
},
"singleGossip"
);
return () => {
connection.removeProgramAccountChangeListener(tokenSubID);
dispose();
};
}
}, [connection, connected, publicKey, selectUserAccounts]);
return (
<AccountsContext.Provider
value={{
userAccounts,
pools,
nativeAccount,
}}
>
{children}
</AccountsContext.Provider>
);
}
export function useNativeAccount() {
const context = useContext(AccountsContext);
return {
account: context.nativeAccount as AccountInfo<Buffer>,
};
}
export const getMultipleAccounts = async (
connection: any,
keys: string[],
commitment: string
) => {
const result = await Promise.all(
chunks(keys, 99).map((chunk) =>
getMultipleAccountsCore(connection, chunk, commitment)
)
);
const array = result
.map(
(a) =>
a.array.filter(acc => !!acc).map((acc) => {
const { data, ...rest } = acc;
const obj = {
...rest,
data: Buffer.from(data[0], "base64"),
} as AccountInfo<Buffer>;
return obj;
}) as AccountInfo<Buffer>[]
)
.flat();
return { keys, array };
};
const getMultipleAccountsCore = async (
connection: any,
keys: string[],
commitment: string
) => {
const args = connection._buildArgs([keys], commitment, "base64");
const unsafeRes = await connection._rpcRequest("getMultipleAccounts", args);
if (unsafeRes.error) {
throw new Error(
"failed to get info about account " + unsafeRes.error.message
);
}
if (unsafeRes.result.value) {
const array = unsafeRes.result.value as AccountInfo<string[]>[];
return { keys, array };
}
// TODO: fix
throw new Error();
};
export function useMint(key?: string | PublicKey) {
const connection = useConnection();
const [mint, setMint] = useState<MintInfo>();
const id = typeof key === "string" ? key : key?.toBase58();
useEffect(() => {
if (!id) {
return;
}
cache
.queryMint(connection, id)
.then(setMint)
.catch((err) =>
notify({
message: err.message,
type: "error",
})
);
const dispose = accountEmitter.onAccount((e) => {
const event = e;
if (event.id === id) {
cache.queryMint(connection, id).then(setMint);
}
});
return () => {
dispose();
};
}, [connection, id]);
return mint;
}
export function useUserAccounts() {
const context = useContext(AccountsContext);
return {
userAccounts: context.userAccounts as TokenAccount[],
};
}
export function useAccount(pubKey?: PublicKey) {
const connection = useConnection();
const [account, setAccount] = useState<TokenAccount>();
const key = pubKey?.toBase58();
useEffect(() => {
const query = async () => {
try {
if (!key) {
return;
}
const acc = await cache.queryAccount(connection, key).catch((err) =>
notify({
message: err.message,
type: "error",
})
);
if (acc) {
setAccount(acc);
}
} catch (err) {
console.error(err);
}
};
query();
const dispose = accountEmitter.onAccount((e) => {
const event = e;
if (event.id === key) {
query();
}
});
return () => {
dispose();
};
}, [connection, key]);
return account;
}
export function useCachedPool() {
const context = useContext(AccountsContext);
return {
pools: context.pools as PoolInfo[],
};
}
export const useSelectedAccount = (account: string) => {
const { userAccounts } = useUserAccounts();
const index = userAccounts.findIndex(
(acc) => acc.pubkey.toBase58() === account
);
if (index !== -1) {
return userAccounts[index];
}
return;
};
export const useAccountByMint = (mint: string) => {
const { userAccounts } = useUserAccounts();
const index = userAccounts.findIndex(
(acc) => acc.info.mint.toBase58() === mint
);
if (index !== -1) {
return userAccounts[index];
}
return;
};
// TODO: expose in spl package
const deserializeAccount = (data: Buffer) => {
const accountInfo = AccountLayout.decode(data);
accountInfo.mint = new PublicKey(accountInfo.mint);
accountInfo.owner = new PublicKey(accountInfo.owner);
accountInfo.amount = u64.fromBuffer(accountInfo.amount);
if (accountInfo.delegateOption === 0) {
accountInfo.delegate = null;
accountInfo.delegatedAmount = new u64(0);
} else {
accountInfo.delegate = new PublicKey(accountInfo.delegate);
accountInfo.delegatedAmount = u64.fromBuffer(accountInfo.delegatedAmount);
}
accountInfo.isInitialized = accountInfo.state !== 0;
accountInfo.isFrozen = accountInfo.state === 2;
if (accountInfo.isNativeOption === 1) {
accountInfo.rentExemptReserve = u64.fromBuffer(accountInfo.isNative);
accountInfo.isNative = true;
} else {
accountInfo.rentExemptReserve = null;
accountInfo.isNative = false;
}
if (accountInfo.closeAuthorityOption === 0) {
accountInfo.closeAuthority = null;
} else {
accountInfo.closeAuthority = new PublicKey(accountInfo.closeAuthority);
}
return accountInfo;
};
// TODO: expose in spl package
const deserializeMint = (data: Buffer) => {
if (data.length !== MintLayout.span) {
throw new Error("Not a valid Mint");
}
const mintInfo = MintLayout.decode(data);
if (mintInfo.mintAuthorityOption === 0) {
mintInfo.mintAuthority = null;
} else {
mintInfo.mintAuthority = new PublicKey(mintInfo.mintAuthority);
}
mintInfo.supply = u64.fromBuffer(mintInfo.supply);
mintInfo.isInitialized = mintInfo.isInitialized !== 0;
if (mintInfo.freezeAuthorityOption === 0) {
mintInfo.freezeAuthority = null;
} else {
mintInfo.freezeAuthority = new PublicKey(mintInfo.freezeAuthority);
}
return mintInfo as MintInfo;
};

295
ui/src/utils/connection.tsx Normal file
View File

@ -0,0 +1,295 @@
import { KnownToken, useLocalStorageState } from "./utils";
import {
Account,
clusterApiUrl,
Connection,
Transaction,
TransactionInstruction,
} from "@solana/web3.js";
import React, { useContext, useEffect, useMemo, useState } from "react";
import { setProgramIds } from "./ids";
import { notify } from "./notifications";
import { ExplorerLink } from "../components/explorerLink";
import contract_keys from "../contract_keys.json";
export type Outcome = {
mint_pk: string,
name: string,
icon: string
};
export type ENV = "mainnet-beta" | "testnet" | "devnet" | "localnet";
export const ENDPOINTS = [
{
name: "mainnet-beta" as ENV,
endpoint: "https://solana-api.projectserum.com/",
},
{ name: "testnet" as ENV, endpoint: clusterApiUrl("testnet") },
{ name: "devnet" as ENV, endpoint: clusterApiUrl("devnet") },
{ name: "localnet" as ENV, endpoint: "http://127.0.0.1:8899" },
];
const DEFAULT = ENDPOINTS[0].endpoint;
const DEFAULT_SLIPPAGE = 0.25;
interface ConnectionConfig {
connection: Connection;
sendConnection: Connection;
endpoint: string;
slippage: number;
setSlippage: (val: number) => void;
env: ENV;
setEndpoint: (val: string) => void;
tokens: KnownToken[];
tokenMap: Map<string, KnownToken>;
}
const ConnectionContext = React.createContext<ConnectionConfig>({
endpoint: DEFAULT,
setEndpoint: () => {},
slippage: DEFAULT_SLIPPAGE,
setSlippage: (val: number) => {},
connection: new Connection(DEFAULT, "recent"),
sendConnection: new Connection(DEFAULT, "recent"),
env: ENDPOINTS[0].name,
tokens: [],
tokenMap: new Map<string, KnownToken>(),
});
export function ConnectionProvider({ children = undefined as any }) {
const [endpoint, setEndpoint] = useLocalStorageState(
"connectionEndpts",
ENDPOINTS[0].endpoint
);
const [slippage, setSlippage] = useLocalStorageState(
"slippage",
DEFAULT_SLIPPAGE.toString()
);
const connection = useMemo(() => new Connection(endpoint, "recent"), [
endpoint,
]);
const sendConnection = useMemo(() => new Connection(endpoint, "recent"), [
endpoint,
]);
const env =
ENDPOINTS.find((end) => end.endpoint === endpoint)?.name ||
ENDPOINTS[0].name;
const [tokens, setTokens] = useState<KnownToken[]>([]);
const [tokenMap, setTokenMap] = useState<Map<string, KnownToken>>(new Map());
useEffect(() => {
// fetch token files
window
.fetch(
`https://raw.githubusercontent.com/solana-labs/token-list/main/src/tokens/mainnet-beta.json`
)
.then((res) => {
return res.json();
})
.then((list: KnownToken[]) => {
const knownMints = list.reduce((map, item) => {
map.set(item.mintAddress, item);
return map;
}, new Map<string, KnownToken>());
contract_keys.outcomes.forEach(function (outcome: any) {
let outcomeTk = {
tokenSymbol: outcome.name,
tokenName: `${contract_keys.contract_name} ${outcome.name}`,
icon: outcome.icon,
mintAddress: outcome.mint_pk,
};
list.push(outcomeTk);
knownMints.set(outcomeTk.mintAddress, outcomeTk);
});
let quoteTk = {
tokenSymbol: "USDC",
tokenName: "USDC",
icon: "https://raw.githubusercontent.com/trustwallet/assets/f3ffd0b9ae2165336279ce2f8db1981a55ce30f8/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png",
mintAddress: contract_keys.quote_mint_pk,
};
knownMints.set(quoteTk.mintAddress, quoteTk);
console.log('knownMints', knownMints);
setTokenMap(knownMints);
setTokens(list);
});
}, [env]);
setProgramIds(env);
// The websocket library solana/web3.js uses closes its websocket connection when the subscription list
// is empty after opening its first time, preventing subsequent subscriptions from receiving responses.
// This is a hack to prevent the list from every getting empty
useEffect(() => {
const id = connection.onAccountChange(new Account().publicKey, () => {});
return () => {
connection.removeAccountChangeListener(id);
};
}, [connection]);
useEffect(() => {
const id = connection.onSlotChange(() => null);
return () => {
connection.removeSlotChangeListener(id);
};
}, [connection]);
useEffect(() => {
const id = sendConnection.onAccountChange(
new Account().publicKey,
() => {}
);
return () => {
sendConnection.removeAccountChangeListener(id);
};
}, [sendConnection]);
useEffect(() => {
const id = sendConnection.onSlotChange(() => null);
return () => {
sendConnection.removeSlotChangeListener(id);
};
}, [sendConnection]);
return (
<ConnectionContext.Provider
value={{
endpoint,
setEndpoint,
slippage: parseFloat(slippage),
setSlippage: (val) => setSlippage(val.toString()),
connection,
sendConnection,
tokens,
tokenMap,
env,
}}
>
{children}
</ConnectionContext.Provider>
);
}
export function useConnection() {
return useContext(ConnectionContext).connection as Connection;
}
export function useSendConnection() {
return useContext(ConnectionContext)?.sendConnection;
}
export function useConnectionConfig() {
const context = useContext(ConnectionContext);
return {
endpoint: context.endpoint,
setEndpoint: context.setEndpoint,
env: context.env,
tokens: context.tokens,
tokenMap: context.tokenMap,
};
}
export function useSlippageConfig() {
const { slippage, setSlippage } = useContext(ConnectionContext);
return { slippage, setSlippage };
}
const getErrorForTransaction = async (connection: Connection, txid: string) => {
// wait for all confirmation before geting transaction
await connection.confirmTransaction(txid, "max");
const tx = await connection.getParsedConfirmedTransaction(txid);
const errors: string[] = [];
if (tx?.meta && tx.meta.logMessages) {
tx.meta.logMessages.forEach((log) => {
const regex = /Error: (.*)/gm;
let m;
while ((m = regex.exec(log)) !== null) {
// This is necessary to avoid infinite loops with zero-width matches
if (m.index === regex.lastIndex) {
regex.lastIndex++;
}
if (m.length > 1) {
errors.push(m[1]);
}
}
});
}
return errors;
};
export const sendTransaction = async (
connection: Connection,
wallet: any,
instructions: TransactionInstruction[],
signers: Account[],
awaitConfirmation = true
) => {
let transaction = new Transaction();
instructions.forEach((instruction) => transaction.add(instruction));
transaction.recentBlockhash = (
await connection.getRecentBlockhash("max")
).blockhash;
transaction.setSigners(
// fee payied by the wallet owner
wallet.publicKey,
...signers.map((s) => s.publicKey)
);
if (signers.length > 0) {
transaction.partialSign(...signers);
}
transaction = await wallet.signTransaction(transaction);
const rawTransaction = transaction.serialize();
let options = {
skipPreflight: true,
commitment: "singleGossip",
};
const txid = await connection.sendRawTransaction(rawTransaction, options);
if (awaitConfirmation) {
const status = (
await connection.confirmTransaction(
txid,
options && (options.commitment as any)
)
).value;
if (status?.err) {
const errors = await getErrorForTransaction(connection, txid);
notify({
message: "Transaction failed...",
description: (
<>
{errors.map((err) => (
<div>{err}</div>
))}
<ExplorerLink address={txid} type="transaction" />
</>
),
type: "error",
});
throw new Error(
`Raw transaction ${txid} failed (${JSON.stringify(status)})`
);
}
}
return txid;
};

View File

@ -0,0 +1,267 @@
import React, {
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from "react";
import {
calculateDependentAmount,
usePoolForBasket,
PoolOperation,
} from "./pools";
import { useMint, useAccountByMint } from "./accounts";
import { MintInfo } from "@solana/spl-token";
import { useConnection, useConnectionConfig } from "./connection";
import { TokenAccount } from "../models";
import { convert, getTokenIcon, getTokenName, KnownToken } from "./utils";
import { useLocation } from "react-router-dom";
import bs58 from "bs58";
import contract_keys from "../contract_keys.json";
export interface CurrencyContextState {
mintAddress: string;
account?: TokenAccount;
mint?: MintInfo;
amount: string;
name: string;
icon?: string;
setAmount: (val: string) => void;
setMint: (mintAddress: string) => void;
convertAmount: () => number;
sufficientBalance: () => boolean;
}
export interface CurrencyPairContextState {
A: CurrencyContextState;
B: CurrencyContextState;
lastTypedAccount: string;
setLastTypedAccount: (mintAddress: string) => void;
setPoolOperation: (swapDirection: PoolOperation) => void;
}
const CurrencyPairContext = React.createContext<CurrencyPairContextState | null>(
null
);
export const convertAmount = (amount: string, mint?: MintInfo) => {
return parseFloat(amount) * Math.pow(10, mint?.decimals || 0);
};
export const useCurrencyLeg = (defaultMint?: string) => {
const { tokenMap } = useConnectionConfig();
const [amount, setAmount] = useState("");
const [mintAddress, setMintAddress] = useState(defaultMint || "");
const account = useAccountByMint(mintAddress);
const mint = useMint(mintAddress);
return useMemo(
() => ({
mintAddress: mintAddress,
account: account,
mint: mint,
amount: amount,
name: getTokenName(tokenMap, mintAddress),
icon: getTokenIcon(tokenMap, mintAddress),
setAmount: setAmount,
setMint: setMintAddress,
convertAmount: () => convertAmount(amount, mint),
sufficientBalance: () =>
account !== undefined && convert(account, mint) >= parseFloat(amount),
}),
[mintAddress, account, mint, amount, tokenMap, setAmount, setMintAddress]
);
};
export function CurrencyPairProvider({
baseMintAddress = "" as string,
quoteMintAddress = "" as string,
children = null as any }) {
const connection = useConnection();
const { tokens } = useConnectionConfig();
const location = useLocation();
const [lastTypedAccount, setLastTypedAccount] = useState("");
const [poolOperation, setPoolOperation] = useState<PoolOperation>(
PoolOperation.Add
);
const base = useCurrencyLeg(baseMintAddress);
const mintAddressA = base.mintAddress;
const setMintAddressA = base.setMint;
const amountA = base.amount;
const setAmountA = base.setAmount;
const quote = useCurrencyLeg(quoteMintAddress);
const mintAddressB = quote.mintAddress;
const setMintAddressB = quote.setMint;
const amountB = quote.amount;
const setAmountB = quote.setAmount;
const pool = usePoolForBasket([base.mintAddress, quote.mintAddress]);
// disabled: doesn't work well with multiple swaps on the same page
// updates browser history on token changes
//useEffect(() => {
//// set history
//const base =
//tokens.find((t) => t.mintAddress === mintAddressA)?.tokenSymbol ||
//mintAddressA;
//const quote =
//tokens.find((t) => t.mintAddress === mintAddressB)?.tokenSymbol ||
//mintAddressB;
//if (base && quote && location.pathname.indexOf("info") < 0) {
//history.push({
//search: `?pair=${base}-${quote}`,
//});
//} else {
//if (mintAddressA && mintAddressB) {
//history.push({
//search: ``,
//});
//} else {
//return;
//}
//}
//}, [mintAddressA, mintAddressB, tokens, history, location.pathname]);
// Updates tokens on location change
useEffect(() => {
if (!location.search && mintAddressA && mintAddressB) {
return;
}
let { defaultBase, defaultQuote } = getDefaultTokens(
tokens,
location.search
);
if (!defaultBase || !defaultQuote) {
return;
}
setMintAddressA(
tokens.find((t) => t.tokenSymbol === defaultBase)?.mintAddress ||
(isValidAddress(defaultBase) ? defaultBase : "") ||
""
);
setMintAddressB(
tokens.find((t) => t.tokenSymbol === defaultQuote)?.mintAddress ||
(isValidAddress(defaultQuote) ? defaultQuote : "") ||
""
);
// mintAddressA and mintAddressB are not included here to prevent infinite loop
// eslint-disable-next-line
}, [location, location.search, setMintAddressA, setMintAddressB, tokens]);
const calculateDependent = useCallback(async () => {
if (pool && mintAddressA && mintAddressB) {
let setDependent;
let amount;
let independent;
if (lastTypedAccount === mintAddressA) {
independent = mintAddressA;
setDependent = setAmountB;
amount = parseFloat(amountA);
} else {
independent = mintAddressB;
setDependent = setAmountA;
amount = parseFloat(amountB);
}
const result = await calculateDependentAmount(
connection,
independent,
amount,
pool,
poolOperation
);
console.log('calculateDependent', amount, result, independent);
if (typeof result === "string") {
setDependent(result);
} else if (result !== undefined && Number.isFinite(result)) {
setDependent(result.toFixed(6));
} else {
setDependent("");
}
}
}, [
pool,
mintAddressA,
mintAddressB,
setAmountA,
setAmountB,
amountA,
amountB,
connection,
lastTypedAccount,
poolOperation,
]);
useEffect(() => {
calculateDependent();
}, [amountB, amountA, lastTypedAccount, calculateDependent]);
return (
<CurrencyPairContext.Provider
value={{
A: base,
B: quote,
lastTypedAccount,
setLastTypedAccount,
setPoolOperation,
}}
>
{children}
</CurrencyPairContext.Provider>
);
}
export const useCurrencyPairState = () => {
const context = useContext(CurrencyPairContext);
return context as CurrencyPairContextState;
};
const isValidAddress = (address: string) => {
try {
const decoded = bs58.decode(address);
return decoded.length === 32;
} catch {
return false;
}
};
function getDefaultTokens(tokens: KnownToken[], search: string) {
let defaultBase = contract_keys.outcomes[0].name;
let defaultQuote = contract_keys.outcomes[1].name;
const nameToToken = tokens.reduce((map, item) => {
map.set(item.tokenSymbol, item);
return map;
}, new Map<string, any>());
if (search) {
const urlParams = new URLSearchParams(search);
const pair = urlParams.get("pair");
if (pair) {
let items = pair.split("-");
if (items.length > 1) {
if (nameToToken.has(items[0]) || isValidAddress(items[0])) {
defaultBase = items[0];
}
if (nameToToken.has(items[1]) || isValidAddress(items[1])) {
defaultQuote = items[1];
}
}
}
}
return {
defaultBase,
defaultQuote,
};
}

View File

@ -0,0 +1,41 @@
import { EventEmitter as Emitter } from "eventemitter3";
export class AccountUpdateEvent {
static type = "AccountUpdate";
id: string;
constructor(id: string) {
this.id = id;
}
}
export class MarketUpdateEvent {
static type = "MarketUpdate";
ids: Set<string>;
constructor(ids: Set<string>) {
this.ids = ids;
}
}
export class EventEmitter {
private emitter = new Emitter();
onMarket(callback: (args: MarketUpdateEvent) => void) {
this.emitter.on(MarketUpdateEvent.type, callback);
return () => this.emitter.removeListener(MarketUpdateEvent.type, callback);
}
onAccount(callback: (args: AccountUpdateEvent) => void) {
this.emitter.on(AccountUpdateEvent.type, callback);
return () => this.emitter.removeListener(AccountUpdateEvent.type, callback);
}
raiseAccountUpdated(id: string) {
this.emitter.emit(AccountUpdateEvent.type, new AccountUpdateEvent(id));
}
raiseMarketUpdated(ids: Set<string>) {
this.emitter.emit(MarketUpdateEvent.type, new MarketUpdateEvent(ids));
}
}

89
ui/src/utils/ids.tsx Normal file
View File

@ -0,0 +1,89 @@
import { PublicKey } from "@solana/web3.js";
export const WRAPPED_SOL_MINT = new PublicKey(
"So11111111111111111111111111111111111111112"
);
let TOKEN_PROGRAM_ID = new PublicKey(
"TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"
);
let SWAP_PROGRAM_ID: PublicKey;
let SWAP_PROGRAM_LEGACY_IDS: PublicKey[];
export const SWAP_PROGRAM_OWNER_FEE_ADDRESS = new PublicKey(
"FinVobfi4tbdMdfN9jhzUuDVqGXfcFnRGX57xHcTWLfW"
);
export const SWAP_HOST_FEE_ADDRESS = process.env.REACT_APP_SWAP_HOST_FEE_ADDRESS
? new PublicKey(`${process.env.REACT_APP_SWAP_HOST_FEE_ADDRESS}`)
: SWAP_PROGRAM_OWNER_FEE_ADDRESS;
console.log(`Host address: ${SWAP_HOST_FEE_ADDRESS?.toBase58()}`);
console.log(`Owner address: ${SWAP_PROGRAM_OWNER_FEE_ADDRESS?.toBase58()}`);
// legacy pools are used to show users contributions in those pools to allow for withdrawals of funds
export const PROGRAM_IDS = [
{
name: "omeganet",
swap: () => ({
current: new PublicKey("Ha4hKUmPyqg9YMGkEsNWbAGQ7TiXt6PKjPv3m4o3isLR"),
legacy: [],
}),
},
{
name: "mainnet-beta",
swap: () => ({
current: new PublicKey("9qvG1zUp8xF1Bi4m6UdRNby1BAAuaDrUxSpv4CmRRMjL"),
legacy: [],
}),
},
{
name: "testnet",
swap: () => ({
current: new PublicKey("2n2dsFSgmPcZ8jkmBZLGUM2nzuFqcBGQ3JEEj6RJJcEg"),
legacy: [
new PublicKey("9tdctNJuFsYZ6VrKfKEuwwbPp4SFdFw3jYBZU8QUtzeX"),
new PublicKey("CrRvVBS4Hmj47TPU3cMukurpmCUYUrdHYxTQBxncBGqw"),
],
}),
},
{
name: "devnet",
swap: () => ({
current: new PublicKey("GKZabbjt1rQ5V8at9axSu5pefGqF4JeHt8f7owt6CHpJ"),
legacy: [
new PublicKey("H1E1G7eD5Rrcy43xvDxXCsjkRggz7MWNMLGJ8YNzJ8PM"),
new PublicKey("CMoteLxSPVPoc7Drcggf3QPg3ue8WPpxYyZTg77UGqHo"),
new PublicKey("EEuPz4iZA5reBUeZj6x1VzoiHfYeHMppSCnHZasRFhYo"),
],
}),
},
{
name: "localnet",
swap: () => ({
current: new PublicKey("J2kyyBU3fwZQg3g1akVG7hzfvkLddatJFwWytP5RZ6PE"),
legacy: [],
}),
},
];
export const setProgramIds = (envName: string) => {
console.log('setProgramIds', envName);
let instance = PROGRAM_IDS.find((env) => env.name === envName);
if (!instance) {
return;
}
let swap = instance.swap();
SWAP_PROGRAM_ID = swap.current;
SWAP_PROGRAM_LEGACY_IDS = swap.legacy;
};
export const programIds = () => {
return {
token: TOKEN_PROGRAM_ID,
swap: SWAP_PROGRAM_ID,
swap_legacy: SWAP_PROGRAM_LEGACY_IDS,
};
};

View File

@ -0,0 +1,33 @@
import React from "react";
import { notification } from "antd";
// import Link from '../components/Link';
export function notify({
message = "",
description = undefined as any,
txid = "",
type = "info",
placement = "bottomLeft",
}) {
if (txid) {
// <Link
// external
// to={'https://explorer.solana.com/tx/' + txid}
// style={{ color: '#0000ff' }}
// >
// View transaction {txid.slice(0, 8)}...{txid.slice(txid.length - 8)}
// </Link>
description = <></>;
}
(notification as any)[type]({
message: <span style={{ color: "black" }}>{message}</span>,
description: (
<span style={{ color: "black", opacity: 0.5 }}>{description}</span>
),
placement,
style: {
backgroundColor: "white",
},
});
}

1156
ui/src/utils/pools.tsx Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,59 @@
import EventEmitter from "eventemitter3";
import { PublicKey } from "@solana/web3.js";
import { notify } from "./notifications";
export class SolongAdapter extends EventEmitter {
_publicKey: any;
_onProcess: boolean;
constructor(providerUrl: string, network: string) {
super();
this._publicKey = null;
this._onProcess = false;
this.connect = this.connect.bind(this);
}
get publicKey() {
return this._publicKey;
}
async signTransaction(transaction: any) {
return (window as any).solong.signTransaction(transaction);
}
connect() {
if (this._onProcess) {
return;
}
if ((window as any).solong === undefined) {
notify({
message: "Solong Error",
description: "Please install solong wallet from Chrome ",
});
return;
}
this._onProcess = true;
console.log("solong helper select account");
(window as any).solong
.selectAccount()
.then((account: any) => {
this._publicKey = new PublicKey(account);
console.log("window solong select:", account, "this:", this);
this.emit("connect", this._publicKey);
})
.catch(() => {
this.disconnect();
})
.finally(() => {
this._onProcess = false;
});
}
disconnect() {
if (this._publicKey) {
this._publicKey = null;
this.emit("disconnect");
}
}
}

197
ui/src/utils/utils.ts Normal file
View File

@ -0,0 +1,197 @@
import { useCallback, useState } from "react";
import { MintInfo } from "@solana/spl-token";
import { PoolInfo, TokenAccount } from "./../models";
import contract_keys from "../contract_keys.json";
export interface KnownToken {
tokenSymbol: string;
tokenName: string;
icon: string;
mintAddress: string;
}
export type KnownTokenMap = Map<string, KnownToken>;
export function useLocalStorageState(key: string, defaultState?: string) {
const [state, setState] = useState(() => {
// NOTE: Not sure if this is ok
const storedState = localStorage.getItem(key);
if (storedState) {
return JSON.parse(storedState);
}
return defaultState;
});
const setLocalStorageState = useCallback(
(newState) => {
const changed = state !== newState;
if (!changed) {
return;
}
setState(newState);
if (newState === null) {
localStorage.removeItem(key);
} else {
localStorage.setItem(key, JSON.stringify(newState));
}
},
[state, key]
);
return [state, setLocalStorageState];
}
// shorten the checksummed version of the input address to have 4 characters at start and end
export function shortenAddress(address: string, chars = 4): string {
return `${address.slice(0, chars)}...${address.slice(-chars)}`;
}
export function getTokenName(
map: KnownTokenMap,
mintAddress: string,
shorten = true,
length = 5
): string {
const knownSymbol = map.get(mintAddress)?.tokenSymbol;
if (knownSymbol) {
return knownSymbol;
}
if (mintAddress === contract_keys.quote_mint_pk) {
return "USDC";
}
return shorten ? `${mintAddress.substring(0, length)}...` : mintAddress;
}
export function getTokenIcon(
map: KnownTokenMap,
mintAddress: string
): string | undefined {
return map.get(mintAddress)?.icon;
}
export function getPoolName(
map: KnownTokenMap,
pool: PoolInfo,
shorten = true
) {
const sorted = pool.pubkeys.holdingMints.map((a) => a.toBase58()).sort();
return sorted.map((item) => getTokenName(map, item, shorten)).join("/");
}
export function isKnownMint(map: KnownTokenMap, mintAddress: string) {
return !!map.get(mintAddress);
}
export const STABLE_COINS = new Set(["USDC", "wUSDC", "USDT"]);
export function chunks<T>(array: T[], size: number): T[][] {
return Array.apply<number, T[], T[][]>(
0,
new Array(Math.ceil(array.length / size))
).map((_, index) => array.slice(index * size, (index + 1) * size));
}
export function convert(
account?: TokenAccount | number,
mint?: MintInfo,
rate: number = 1.0
): number {
if (!account) {
return 0;
}
const amount =
typeof account === "number" ? account : account.info.amount?.toNumber();
const precision = Math.pow(10, mint?.decimals || 0);
return (amount / precision) * rate;
}
var SI_SYMBOL = ["", "k", "M", "G", "T", "P", "E"];
const abbreviateNumber = (number: number, precision: number) => {
let tier = (Math.log10(number) / 3) | 0;
let scaled = number;
let suffix = SI_SYMBOL[tier];
if (tier !== 0) {
let scale = Math.pow(10, tier * 3);
scaled = number / scale;
}
return scaled.toFixed(precision) + suffix;
};
const format = (val: number, precision: number, abbr: boolean) =>
abbr ? abbreviateNumber(val, precision) : val.toFixed(precision);
export function formatTokenAmount(
account?: TokenAccount,
mint?: MintInfo,
rate: number = 1.0,
prefix = "",
suffix = "",
precision = 6,
abbr = false
): string {
if (!account) {
return "";
}
return `${[prefix]}${format(
convert(account, mint, rate),
precision,
abbr
)}${suffix}`;
}
export const formatUSD = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
});
export const formatNumber = new Intl.NumberFormat("en-US", {
style: "decimal",
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
export const formatPct = new Intl.NumberFormat("en-US", {
style: "percent",
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
export const formatPriceNumber = new Intl.NumberFormat("en-US", {
style: "decimal",
minimumFractionDigits: 2,
maximumFractionDigits: 8,
});
// returns a Color from a 4 color array, green to red, depending on the index
// of the closer (up) checkpoint number from the value
export const colorWarning = (value = 0, valueCheckpoints = [1, 3, 5, 100]) => {
const defaultIndex = 1;
const colorCodes = ["#27ae60", "inherit", "#f3841e", "#ff3945"];
if (value > valueCheckpoints[valueCheckpoints.length - 1]) {
return colorCodes[defaultIndex];
}
const closest = [...valueCheckpoints].sort((a, b) => {
const first = a - value < 0 ? Number.POSITIVE_INFINITY : a - value;
const second = b - value < 0 ? Number.POSITIVE_INFINITY : b - value;
if (first < second) {
return -1;
} else if (first > second) {
return 1;
}
return 0;
})[0];
const index = valueCheckpoints.indexOf(closest);
if (index !== -1) {
return colorCodes[index];
}
return colorCodes[defaultIndex];
};

90
ui/src/utils/wallet.tsx Normal file
View File

@ -0,0 +1,90 @@
import React, { useContext, useEffect, useMemo, useState } from "react";
import Wallet from "@project-serum/sol-wallet-adapter";
import { notify } from "./notifications";
import { useConnectionConfig } from "./connection";
import { useLocalStorageState } from "./utils";
import { SolongAdapter } from "./solong_adapter";
export const WALLET_PROVIDERS = [
{ name: "sollet.io", url: "https://www.sollet.io" },
{ name: "solongwallet.com", url: "http://solongwallet.com" },
{ name: "solflare.com", url: "https://solflare.com/access-wallet" },
{ name: "mathwallet.org", url: "https://www.mathwallet.org" },
];
const WalletContext = React.createContext<any>(null);
export function WalletProvider({ children = null as any }) {
const { endpoint } = useConnectionConfig();
const [providerUrl, setProviderUrl] = useLocalStorageState(
"walletProvider",
"https://www.sollet.io"
);
const wallet = useMemo(() => {
console.log("use new provider:", providerUrl, " endpoint:", endpoint);
if (providerUrl === "http://solongwallet.com") {
return new SolongAdapter(providerUrl, endpoint);
} else {
return new Wallet(providerUrl, endpoint);
}
}, [providerUrl, endpoint]);
const [connected, setConnected] = useState(false);
useEffect(() => {
console.log("trying to connect");
wallet.on("connect", () => {
console.log("connected");
setConnected(true);
let walletPublicKey = wallet.publicKey.toBase58();
let keyToDisplay =
walletPublicKey.length > 20
? `${walletPublicKey.substring(0, 7)}.....${walletPublicKey.substring(
walletPublicKey.length - 7,
walletPublicKey.length
)}`
: walletPublicKey;
notify({
message: "Wallet update",
description: "Connected to wallet " + keyToDisplay,
});
});
wallet.on("disconnect", () => {
setConnected(false);
notify({
message: "Wallet update",
description: "Disconnected from wallet",
});
});
return () => {
wallet.disconnect();
setConnected(false);
};
}, [wallet]);
return (
<WalletContext.Provider
value={{
wallet,
connected,
providerUrl,
setProviderUrl,
providerName:
WALLET_PROVIDERS.find(({ url }) => url === providerUrl)?.name ??
providerUrl,
}}
>
{children}
</WalletContext.Provider>
);
}
export function useWallet() {
const context = useContext(WalletContext);
return {
connected: context.connected,
wallet: context.wallet,
providerUrl: context.providerUrl,
setProvider: context.setProviderUrl,
providerName: context.providerName,
};
}

21
ui/tsconfig.json Normal file
View File

@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "es5",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "esnext",
"moduleResolution": "node",
"downlevelIteration": true,
"resolveJsonModule": true,
"noEmit": true,
"typeRoots": ["./types"],
"jsx": "react",
"isolatedModules": true
},
"include": ["src"]
}

12509
ui/yarn.lock Normal file

File diff suppressed because it is too large Load Diff