├── .gitignore ├── Cargo.lock ├── Cargo.toml ├── README.md ├── admin ├── Cargo.toml └── src │ ├── error.rs │ ├── init.rs │ ├── main.rs │ ├── member_account.rs │ ├── pool_account.rs │ └── proof_account.rs ├── api ├── Cargo.toml └── src │ ├── consts.rs │ ├── error.rs │ ├── event.rs │ ├── instruction.rs │ ├── lib.rs │ ├── loaders.rs │ ├── sdk.rs │ └── state │ ├── member.rs │ ├── mod.rs │ ├── pool.rs │ └── share.rs ├── docker-compose.yml ├── init-db └── 01_setup.sql ├── program ├── Cargo.toml └── src │ ├── attribute.rs │ ├── claim.rs │ ├── commit.rs │ ├── join.rs │ ├── launch.rs │ ├── lib.rs │ ├── open_share.rs │ ├── open_stake.rs │ ├── stake.rs │ ├── submit.rs │ └── unstake.rs ├── rust-toolchain.toml ├── server ├── .env.example ├── Cargo.toml ├── README.md └── src │ ├── aggregator.rs │ ├── contributions.rs │ ├── database.rs │ ├── error.rs │ ├── handlers.rs │ ├── main.rs │ ├── operator.rs │ ├── tx │ ├── mod.rs │ ├── submit.rs │ └── validate.rs │ ├── utils.rs │ └── webhook.rs └── types ├── Cargo.toml └── src └── lib.rs /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | target 3 | test-ledger 4 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | resolver = "2" 3 | members = ["admin", "api", "program", "server", "types"] 4 | 5 | [workspace.package] 6 | version = "1.7.0-beta" 7 | edition = "2021" 8 | license = "Apache-2.0" 9 | homepage = "https://ore.supply" 10 | documentation = "https://docs.rs/ore-pool-api/latest/ore_pool_api/" 11 | repository = "https://github.com/regolith-labs/ore-pool" 12 | readme = "./README.md" 13 | keywords = ["solana", "crypto", "mining"] 14 | 15 | [workspace.dependencies] 16 | actix-cors = "0.7" 17 | actix-web = "4.9" 18 | array-const-fn-init = "0.1.1" 19 | base64 = "0.22.1" 20 | bincode = "1.3.3" 21 | bytemuck = "1.14.3" 22 | bytemuck_derive = "1.7.0" 23 | cached = "0.54.0" 24 | const-crypto = "0.1.0" 25 | deadpool-postgres = "0.12" 26 | drillx = { features = ["solana"], version = "2.2" } 27 | env_logger = "0.11" 28 | futures = "0.3" 29 | futures-channel = "0.3" 30 | futures-util = "0.3" 31 | log = "0.4" 32 | num_enum = "0.7.2" 33 | ore-api = "3.6.0-beta" 34 | ore-boost-api = "4.0.0-beta" 35 | ore-pool-api = { path = "api" } 36 | ore-pool-types = { path = "types" } 37 | postgres-types = "0.2.6" 38 | reqwest = { version = "0.12", features = ["json"] } 39 | serde = { features = ["derive"], version = "1.0" } 40 | serde_json = "1.0" 41 | sha3 = "0.10" 42 | solana-account-decoder = "=2.1" 43 | solana-client = "=2.1" 44 | solana-program = "=2.1" 45 | solana-sdk = "=2.1" 46 | solana-transaction-status = "=2.1" 47 | spl-token = { features = ["no-entrypoint"], version = "^4" } 48 | spl-associated-token-account = { features = ["no-entrypoint"], version = "^6" } 49 | static_assertions = "1.1.0" 50 | steel = { features = ["spl"], version = "4.0" } 51 | thiserror = "1.0.57" 52 | tokio = "1.39" 53 | tokio-postgres = "0.7" 54 | rand = "0.8.5" 55 | 56 | [patch.crates-io] 57 | 58 | [profile.release] 59 | overflow-checks = true 60 | 61 | [profile.dev] 62 | overflow-checks = true 63 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ORE Pool (beta) 2 | 3 | **Infrastructure for operating ORE mining pools.** 4 | 5 | ## Admin 6 | Must `cargo run` the [admin application](./admin/src/main.rs) before starting server. 7 | 8 | Creates the pool and member accounts on-chain which the server expects to exist upon starting. A member account is created because we need an account to write the pool commissions to. You can manage this member account (balance, claim, etc.) from the `ore-cli`. 9 | ```sh 10 | # cd ./admin 11 | COMMAND="init" RPC_URL="" KEYPAIR_PATH="/my/path/id.json" POOL_URL="" cargo run --release 12 | ``` 13 | 14 | ## Server 15 | Start the server. Parameterized via [env vars](./server/.env.example). 16 | ```sh 17 | # cd ./server 18 | RPC_URL="" KEYPAIR_PATH="/my/path/id.json" DB_URL="" ATTR_EPOCH="60" HELIUS_AUTH_TOKEN="" OPERATOR_COMMISSION="" RUST_LOG=info cargo run --release 19 | ``` 20 | 21 | ## Webhook 22 | The server depends on a [Helius webhook](https://docs.helius.dev/webhooks-and-websockets/what-are-webhooks), for parsing the mining events asynchronously. 23 | - You'll need to create the webhook manually in the helius dashboard. It should be of type `raw`. 24 | - Also will need to generate an auth token that helius will include in their POST requests to your server. Pass this as an env var to the server. 25 | - Creating new webhooks requires specifying the account address(es) to listen for. You want to put the proof account pubkey that belongs to the pool. You can find this pubkey by running the `proof-account` command in the [admin server](./admin/src/main.rs). 26 | 27 | ## Considerations 28 | - This implementation is still in active development and is subject to breaking changes. 29 | - The idea is for this to be a reference implementation for operators. 30 | - Feel free to fork this repo and add your custom logic. 31 | - Ofc we're accepting PRs / contributions. Please help us reach a solid v1.0.0. 32 | - This implementation is integrated with the official `ore-cli`. 33 | - If you fork and change things, just make sure you serve the same HTTP paths that the `ore-cli` is interfacing with. If you do that, people should be able to participate in your pool with no additional installs or changes to their client. 34 | - For reference, you'll find the required HTTP paths [here](./server/src/contributor.rs) and also the client-side API types [here](./types/src/lib.rs). 35 | 36 | ## Local database 37 | To spin up the database locally: 38 | ``` 39 | docker-compose up 40 | ``` 41 | -------------------------------------------------------------------------------- /admin/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "admin" 3 | version.workspace = true 4 | edition.workspace = true 5 | license.workspace = true 6 | homepage.workspace = true 7 | documentation.workspace = true 8 | repository.workspace = true 9 | readme.workspace = true 10 | keywords.workspace = true 11 | 12 | [dependencies] 13 | ore-api.workspace = true 14 | ore-boost-api.workspace = true 15 | ore-pool-api.workspace = true 16 | solana-sdk.workspace = true 17 | solana-client.workspace = true 18 | solana-program.workspace = true 19 | steel.workspace = true 20 | thiserror.workspace = true 21 | tokio.workspace = true 22 | -------------------------------------------------------------------------------- /admin/src/error.rs: -------------------------------------------------------------------------------- 1 | use std::env::VarError; 2 | 3 | #[derive(thiserror::Error, Debug)] 4 | pub enum Error { 5 | #[error("std env")] 6 | StdEnv(#[from] VarError), 7 | #[error("std io")] 8 | StdIo(#[from] std::io::Error), 9 | #[error("could not ready keypair from provided path: {0}")] 10 | KeypairRead(String), 11 | #[error("pool api")] 12 | PoolApi(#[from] ore_pool_api::error::ApiError), 13 | #[error("solana client")] 14 | SolanaClient(#[from] solana_client::client_error::ClientError), 15 | #[error("solana program")] 16 | SolaanProgram(#[from] solana_program::program_error::ProgramError), 17 | #[error("solana parse pubkey")] 18 | SolanaParsePubkey(#[from] solana_sdk::pubkey::ParsePubkeyError), 19 | #[error("missing pool url")] 20 | MissingPoolUrl, 21 | #[error("invalid command")] 22 | InvalidCommand, 23 | #[error("member pool mismatch")] 24 | MemberPoolMismatch, 25 | } 26 | -------------------------------------------------------------------------------- /admin/src/init.rs: -------------------------------------------------------------------------------- 1 | use ore_pool_api::state::pool_pda; 2 | use solana_client::nonblocking::rpc_client::RpcClient; 3 | use solana_sdk::compute_budget::ComputeBudgetInstruction; 4 | use solana_sdk::transaction::Transaction; 5 | use solana_sdk::{signature::Keypair, signer::Signer}; 6 | 7 | use crate::error::Error; 8 | 9 | pub async fn init( 10 | rpc_client: &RpcClient, 11 | keypair: &Keypair, 12 | pool_url: Option, 13 | ) -> Result<(), Error> { 14 | // parse arguments 15 | let pool_url = pool_url.ok_or(Error::MissingPoolUrl)?; 16 | let pool_authority = keypair.pubkey(); 17 | 18 | // Submit create if pool or reservation account does not exist 19 | let (pool_address, _) = pool_pda(pool_authority); 20 | let pool_data = rpc_client.get_account_data(&pool_address).await; 21 | println!("pool address: {:?}", pool_address); 22 | if pool_data.is_err() { 23 | let cu_budget = ComputeBudgetInstruction::set_compute_unit_limit(1_000_000); 24 | let cu_price = ComputeBudgetInstruction::set_compute_unit_price(1_000_000); 25 | let launch_ix = ore_pool_api::sdk::launch(pool_authority, pool_authority, pool_url)?; 26 | let mut tx = 27 | Transaction::new_with_payer(&[cu_budget, cu_price, launch_ix], Some(&keypair.pubkey())); 28 | let hash = rpc_client.get_latest_blockhash().await?; 29 | tx.sign(&[keypair], hash); 30 | let sig = rpc_client.send_transaction(&tx).await?; 31 | println!("OK: {:?}", sig); 32 | } 33 | 34 | // get or create member account 35 | let (member_address, _) = ore_pool_api::state::member_pda(pool_authority, pool_address); 36 | let member_data = rpc_client.get_account_data(&member_address).await; 37 | println!("member address: {:?}", member_address); 38 | if member_data.is_err() { 39 | let cu_budget = ComputeBudgetInstruction::set_compute_unit_limit(1_000_000); 40 | let cu_price = ComputeBudgetInstruction::set_compute_unit_price(1_000_000); 41 | let join_ix = ore_pool_api::sdk::join(pool_authority, pool_address, pool_authority); 42 | let mut tx = 43 | Transaction::new_with_payer(&[cu_budget, cu_price, join_ix], Some(&keypair.pubkey())); 44 | let hash = rpc_client.get_latest_blockhash().await?; 45 | tx.sign(&[keypair], hash); 46 | let sig = rpc_client.send_transaction(&tx).await?; 47 | println!("OK: {:?}", sig); 48 | } 49 | 50 | Ok(()) 51 | } 52 | -------------------------------------------------------------------------------- /admin/src/main.rs: -------------------------------------------------------------------------------- 1 | use std::str::FromStr; 2 | 3 | use solana_client::nonblocking::rpc_client::RpcClient; 4 | use solana_sdk::pubkey::Pubkey; 5 | use solana_sdk::{commitment_config::CommitmentConfig, signature::Keypair, signer::EncodableKey}; 6 | 7 | mod error; 8 | mod init; 9 | mod member_account; 10 | mod pool_account; 11 | mod proof_account; 12 | 13 | #[tokio::main] 14 | async fn main() -> Result<(), error::Error> { 15 | // parse resources 16 | let command = command()?; 17 | let keypair = keypair()?; 18 | let rpc_client = rpc_client()?; 19 | let pool_url = pool_url(); 20 | let pubkey = pubkey(); 21 | // run 22 | match command.as_str() { 23 | "init" => init::init(&rpc_client, &keypair, pool_url).await, 24 | "pool-account" => pool_account::pool_account(&rpc_client, &keypair).await, 25 | "proof-account" => proof_account::proof_account(&rpc_client, &keypair).await, 26 | "member-account" => member_account::member_account(&rpc_client, &keypair).await, 27 | "member-account-lookup" => { 28 | member_account::member_account_lookup(&rpc_client, &keypair, pubkey).await 29 | } 30 | "member-account-gpa" => member_account::member_account_gpa(&rpc_client, pubkey).await, 31 | _ => Err(error::Error::InvalidCommand), 32 | } 33 | } 34 | 35 | fn command() -> Result { 36 | std::env::var("COMMAND").map_err(From::from) 37 | } 38 | 39 | fn rpc_client() -> Result { 40 | std::env::var("RPC_URL") 41 | .map(|url| RpcClient::new_with_commitment(url, CommitmentConfig::confirmed())) 42 | .map_err(From::from) 43 | } 44 | 45 | fn keypair() -> Result { 46 | let keypair_path = std::env::var("KEYPAIR_PATH")?; 47 | let keypair = Keypair::read_from_file(keypair_path.clone()) 48 | .map_err(|_| error::Error::KeypairRead(keypair_path))?; 49 | Ok(keypair) 50 | } 51 | 52 | fn pool_url() -> Option { 53 | std::env::var("POOL_URL").ok() 54 | } 55 | 56 | fn pubkey() -> Result { 57 | let pubkey_str = std::env::var("PUBKEY")?; 58 | let pubkey = Pubkey::from_str(pubkey_str.as_str())?; 59 | Ok(pubkey) 60 | } 61 | -------------------------------------------------------------------------------- /admin/src/member_account.rs: -------------------------------------------------------------------------------- 1 | use ore_pool_api::state::Member; 2 | use solana_client::nonblocking::rpc_client::RpcClient; 3 | use solana_sdk::pubkey::Pubkey; 4 | use solana_sdk::{signature::Keypair, signer::Signer}; 5 | use steel::AccountDeserialize; 6 | 7 | use crate::error::Error; 8 | 9 | /// the member account is of interest 10 | /// because this is where the operator commissions will be attributed. 11 | /// this command will fetch and print the address and decoded data of the member account. 12 | /// to manage this account (claim, stake, etc), use the ore-cli. 13 | pub async fn member_account(rpc_client: &RpcClient, keypair: &Keypair) -> Result<(), Error> { 14 | let (pool_pda, _) = ore_pool_api::state::pool_pda(keypair.pubkey()); 15 | let (member_pda, _) = ore_pool_api::state::member_pda(keypair.pubkey(), pool_pda); 16 | println!("member address: {:?}", member_pda); 17 | let data = rpc_client.get_account_data(&member_pda).await?; 18 | let member = Member::try_from_bytes(data.as_slice())?; 19 | println!("{:?}", member); 20 | Ok(()) 21 | } 22 | 23 | pub async fn member_account_lookup( 24 | rpc_client: &RpcClient, 25 | keypair: &Keypair, 26 | pubkey: Result, 27 | ) -> Result<(), Error> { 28 | let pubkey = pubkey?; 29 | let (pool_pda, _) = ore_pool_api::state::pool_pda(keypair.pubkey()); 30 | let (member_pda, _) = ore_pool_api::state::member_pda(pubkey, pool_pda); 31 | println!("member address: {:?}", member_pda); 32 | let data = rpc_client.get_account_data(&member_pda).await?; 33 | let member = Member::try_from_bytes(data.as_slice())?; 34 | println!("{:?}", member); 35 | Ok(()) 36 | } 37 | 38 | pub async fn member_account_gpa( 39 | rpc_client: &RpcClient, 40 | pool_authority: Result, 41 | ) -> Result<(), Error> { 42 | let pool_authority = pool_authority?; 43 | let (pool_pda, _) = ore_pool_api::state::pool_pda(pool_authority); 44 | let vec = rpc_client.get_program_accounts(&ore_pool_api::ID).await?; 45 | let vec: Vec<_> = vec 46 | .into_iter() 47 | .flat_map(|(pubkey, account)| { 48 | let member = Member::try_from_bytes(account.data.as_slice())?; 49 | if member.pool.eq(&pool_pda) { 50 | Ok((pubkey, *member)) 51 | } else { 52 | Err(Error::MemberPoolMismatch) 53 | } 54 | }) 55 | .collect(); 56 | // create a TSV file with member data 57 | let file_path = "members.tsv"; 58 | let mut file = std::fs::File::create(file_path)?; 59 | // write header 60 | use std::io::Write; 61 | writeln!(file, "pubkey\tid\tauthority\tbalance\ttotal_balance")?; 62 | // write data rows 63 | for (pubkey, member) in vec { 64 | writeln!( 65 | file, 66 | "{}\t{}\t{}\t{}\t{}", 67 | pubkey, member.id, member.authority, member.balance, member.total_balance 68 | )?; 69 | } 70 | println!("Member data written to {}", file_path); 71 | Ok(()) 72 | } 73 | -------------------------------------------------------------------------------- /admin/src/pool_account.rs: -------------------------------------------------------------------------------- 1 | use ore_pool_api::state::Pool; 2 | use solana_client::nonblocking::rpc_client::RpcClient; 3 | use solana_sdk::{signature::Keypair, signer::Signer}; 4 | use steel::AccountDeserialize; 5 | 6 | use crate::error::Error; 7 | 8 | pub async fn pool_account(rpc_client: &RpcClient, keypair: &Keypair) -> Result<(), Error> { 9 | let (pool_pda, _) = ore_pool_api::state::pool_pda(keypair.pubkey()); 10 | println!("pool address: {:?}", pool_pda); 11 | let pool = rpc_client.get_account_data(&pool_pda).await?; 12 | let pool = Pool::try_from_bytes(pool.as_slice())?; 13 | println!("pool: {:?}", pool); 14 | Ok(()) 15 | } 16 | -------------------------------------------------------------------------------- /admin/src/proof_account.rs: -------------------------------------------------------------------------------- 1 | use ore_api::state::Proof; 2 | use solana_client::nonblocking::rpc_client::RpcClient; 3 | use solana_sdk::{signature::Keypair, signer::Signer}; 4 | use steel::*; 5 | 6 | use crate::error::Error; 7 | 8 | pub async fn proof_account(rpc_client: &RpcClient, keypair: &Keypair) -> Result<(), Error> { 9 | let (pool_pda, _) = ore_pool_api::state::pool_pda(keypair.pubkey()); 10 | let (proof_pda, _) = ore_pool_api::state::pool_proof_pda(pool_pda); 11 | let proof = rpc_client.get_account_data(&proof_pda).await?; 12 | let proof = Proof::try_from_bytes(proof.as_slice())?; 13 | println!("proof address: {:?}", proof_pda); 14 | println!("proof: {:?}", proof); 15 | Ok(()) 16 | } 17 | -------------------------------------------------------------------------------- /api/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "ore-pool-api" 3 | description = "API for interacting with the ORE mining pool program" 4 | version.workspace = true 5 | edition.workspace = true 6 | license.workspace = true 7 | homepage.workspace = true 8 | documentation.workspace = true 9 | repository.workspace = true 10 | keywords.workspace = true 11 | 12 | [dependencies] 13 | array-const-fn-init.workspace = true 14 | bytemuck.workspace = true 15 | const-crypto.workspace = true 16 | drillx.workspace = true 17 | num_enum.workspace = true 18 | ore-api.workspace = true 19 | ore-boost-api.workspace = true 20 | solana-program.workspace = true 21 | spl-token.workspace = true 22 | spl-associated-token-account.workspace = true 23 | static_assertions.workspace = true 24 | steel.workspace = true 25 | thiserror.workspace = true 26 | 27 | -------------------------------------------------------------------------------- /api/src/consts.rs: -------------------------------------------------------------------------------- 1 | use solana_program::{pubkey, pubkey::Pubkey}; 2 | 3 | /// The seed of the member account PDA. 4 | pub const MEMBER: &[u8] = b"member"; 5 | 6 | /// The seed of the pool account PDA. 7 | pub const POOL: &[u8] = b"pool"; 8 | 9 | /// The seed of the share account PDA. 10 | pub const SHARE: &[u8] = b"share"; 11 | 12 | /// The seed of the migration account PDA. 13 | pub const MIGRATION: &[u8] = b"migration"; 14 | 15 | /// The authority allowed to run migrations. 16 | pub const ADMIN_ADDRESS: Pubkey = pubkey!("HBUh9g46wk2X89CvaNN15UmsznP59rh6od1h8JwYAopk"); 17 | 18 | /// The legacy boost program ID. 19 | pub const LEGACY_BOOST_PROGRAM_ID: Pubkey = pubkey!("boostmPwypNUQu8qZ8RoWt5DXyYSVYxnBXqbbrGjecc"); 20 | -------------------------------------------------------------------------------- /api/src/error.rs: -------------------------------------------------------------------------------- 1 | use steel::*; 2 | 3 | #[repr(u32)] 4 | #[derive(Debug, Error, Clone, Copy, PartialEq, Eq, IntoPrimitive)] 5 | pub enum PoolError { 6 | #[error("Missing mining reward")] 7 | MissingMiningReward = 0, 8 | #[error("Could not parse mining reward")] 9 | CouldNotParseMiningReward = 1, 10 | #[error("Staking is in withdraw only mode")] 11 | WithdrawOnlyMode = 2, 12 | #[error("Cannot attribute more rewards than are currently claimable")] 13 | AttributionTooLarge = 3, 14 | } 15 | 16 | #[derive(Debug, Error)] 17 | pub enum ApiError { 18 | #[error("operator server url must be 128 bytes or less")] 19 | UrlTooLarge, 20 | } 21 | 22 | error!(PoolError); 23 | -------------------------------------------------------------------------------- /api/src/event.rs: -------------------------------------------------------------------------------- 1 | use steel::*; 2 | 3 | #[repr(C)] 4 | #[derive(Clone, Copy, Debug, PartialEq, Pod, Zeroable)] 5 | pub struct UnstakeEvent { 6 | /// the authority of the share account 7 | pub authority: Pubkey, 8 | /// the share account 9 | pub share: Pubkey, 10 | /// the mint (target of the staking) 11 | pub mint: Pubkey, 12 | /// latest balance 13 | pub balance: u64, 14 | } 15 | 16 | event!(UnstakeEvent); 17 | -------------------------------------------------------------------------------- /api/src/instruction.rs: -------------------------------------------------------------------------------- 1 | use steel::*; 2 | 3 | #[repr(u8)] 4 | #[derive(Clone, Copy, Debug, Eq, PartialEq, TryFromPrimitive)] 5 | pub enum PoolInstruction { 6 | // User 7 | Claim = 0, 8 | Join = 1, 9 | #[deprecated( 10 | since = "0.3.0", 11 | note = "Staking has moved to the global boost program" 12 | )] 13 | OpenShare = 2, 14 | #[deprecated( 15 | since = "0.3.0", 16 | note = "Staking has moved to the global boost program" 17 | )] 18 | Stake = 3, 19 | Unstake = 4, 20 | 21 | // Operator 22 | Attribute = 100, 23 | #[deprecated( 24 | since = "0.3.0", 25 | note = "Staking has moved to the global boost program" 26 | )] 27 | Commit = 101, 28 | Launch = 102, 29 | #[deprecated( 30 | since = "0.3.0", 31 | note = "Staking has moved to the global boost program" 32 | )] 33 | OpenStake = 103, 34 | Submit = 104, 35 | 36 | // Migration 37 | MigratePool = 200, 38 | MigrateMemberBalance = 201, 39 | QuickMigrate = 202, 40 | } 41 | 42 | #[repr(C)] 43 | #[derive(Clone, Copy, Debug, Pod, Zeroable)] 44 | pub struct Attribute { 45 | pub total_balance: [u8; 8], 46 | } 47 | 48 | #[repr(C)] 49 | #[derive(Clone, Copy, Debug, Pod, Zeroable)] 50 | pub struct Claim { 51 | pub amount: [u8; 8], 52 | #[deprecated(since = "0.1.3", note = "Bumps are no longer required")] 53 | pub pool_bump: u8, 54 | } 55 | 56 | #[deprecated( 57 | since = "0.3.0", 58 | note = "Staking has moved to the global boost program" 59 | )] 60 | #[repr(C)] 61 | #[derive(Clone, Copy, Debug, Pod, Zeroable)] 62 | pub struct Commit {} 63 | 64 | #[repr(C)] 65 | #[derive(Clone, Copy, Debug, Pod, Zeroable)] 66 | pub struct Launch { 67 | #[deprecated(since = "0.1.3", note = "Bumps are no longer required")] 68 | pub pool_bump: u8, 69 | #[deprecated(since = "0.1.3", note = "Bumps are no longer required")] 70 | pub proof_bump: u8, 71 | pub url: [u8; 128], 72 | } 73 | 74 | #[deprecated( 75 | since = "0.3.0", 76 | note = "Staking has moved to the global boost program" 77 | )] 78 | #[repr(C)] 79 | #[derive(Clone, Copy, Debug, Pod, Zeroable)] 80 | pub struct OpenShare { 81 | pub share_bump: u8, 82 | } 83 | 84 | #[deprecated( 85 | since = "0.3.0", 86 | note = "Staking has moved to the global boost program" 87 | )] 88 | #[repr(C)] 89 | #[derive(Clone, Copy, Debug, Pod, Zeroable)] 90 | pub struct OpenStake {} 91 | 92 | #[repr(C)] 93 | #[derive(Clone, Copy, Debug, Pod, Zeroable)] 94 | pub struct Join { 95 | #[deprecated(since = "0.1.3", note = "Bumps are no longer required")] 96 | pub member_bump: u8, 97 | } 98 | 99 | #[deprecated( 100 | since = "0.3.0", 101 | note = "Staking has moved to the global boost program" 102 | )] 103 | #[repr(C)] 104 | #[derive(Clone, Copy, Debug, Pod, Zeroable)] 105 | pub struct Stake { 106 | pub amount: [u8; 8], 107 | } 108 | 109 | #[repr(C)] 110 | #[derive(Clone, Copy, Debug, Pod, Zeroable)] 111 | pub struct Submit { 112 | pub attestation: [u8; 32], 113 | pub digest: [u8; 16], 114 | pub nonce: [u8; 8], 115 | } 116 | 117 | #[repr(C)] 118 | #[derive(Clone, Copy, Debug, Pod, Zeroable)] 119 | pub struct Unstake { 120 | pub amount: [u8; 8], 121 | } 122 | 123 | instruction!(PoolInstruction, Attribute); 124 | instruction!(PoolInstruction, Claim); 125 | instruction!(PoolInstruction, Commit); 126 | instruction!(PoolInstruction, Launch); 127 | instruction!(PoolInstruction, OpenShare); 128 | instruction!(PoolInstruction, OpenStake); 129 | instruction!(PoolInstruction, Join); 130 | instruction!(PoolInstruction, Stake); 131 | instruction!(PoolInstruction, Submit); 132 | instruction!(PoolInstruction, Unstake); 133 | -------------------------------------------------------------------------------- /api/src/lib.rs: -------------------------------------------------------------------------------- 1 | pub mod consts; 2 | pub mod error; 3 | pub mod event; 4 | #[allow(deprecated)] 5 | pub mod instruction; 6 | pub mod loaders; 7 | pub mod sdk; 8 | pub mod state; 9 | 10 | pub mod prelude { 11 | pub use crate::consts::*; 12 | pub use crate::error::*; 13 | pub use crate::event::*; 14 | pub use crate::instruction::*; 15 | pub use crate::loaders::*; 16 | pub use crate::sdk::*; 17 | pub use crate::state::*; 18 | } 19 | 20 | use steel::*; 21 | 22 | declare_id!("poo1sKMYsZtDDS7og73L68etJQYyn6KXhXTLz1hizJc"); 23 | 24 | -------------------------------------------------------------------------------- /api/src/loaders.rs: -------------------------------------------------------------------------------- 1 | use steel::*; 2 | 3 | use crate::state::{Member, Pool, Share}; 4 | 5 | /// Errors if: 6 | /// - Owner is not pool program. 7 | /// - Data is empty. 8 | /// - Data cannot be parsed to a member account. 9 | /// - Member authority is not expected value. 10 | /// - Expected to be writable, but is not. 11 | pub fn load_member( 12 | info: &AccountInfo<'_>, 13 | authority: &Pubkey, 14 | pool: &Pubkey, 15 | is_writable: bool, 16 | ) -> Result<(), ProgramError> { 17 | if info.owner.ne(&crate::id()) { 18 | return Err(ProgramError::InvalidAccountOwner); 19 | } 20 | 21 | if info.data_is_empty() { 22 | return Err(ProgramError::UninitializedAccount); 23 | } 24 | 25 | let member_data = info.data.borrow(); 26 | let member = Member::try_from_bytes(&member_data)?; 27 | 28 | if member.authority.ne(authority) { 29 | return Err(ProgramError::InvalidAccountData); 30 | } 31 | 32 | if member.pool.ne(pool) { 33 | return Err(ProgramError::InvalidAccountData); 34 | } 35 | 36 | if is_writable && !info.is_writable { 37 | return Err(ProgramError::InvalidAccountData); 38 | } 39 | 40 | Ok(()) 41 | } 42 | 43 | /// Errors if: 44 | /// - Owner is not pool program. 45 | /// - Data is empty. 46 | /// - Account discriminator does not match expected value. 47 | /// - Expected to be writable, but is not. 48 | pub fn load_any_member( 49 | info: &AccountInfo<'_>, 50 | pool: &Pubkey, 51 | is_writable: bool, 52 | ) -> Result<(), ProgramError> { 53 | if info.owner.ne(&crate::id()) { 54 | return Err(ProgramError::InvalidAccountOwner); 55 | } 56 | 57 | if info.data_is_empty() { 58 | return Err(ProgramError::UninitializedAccount); 59 | } 60 | 61 | let member_data = info.data.borrow(); 62 | let member = Member::try_from_bytes(&member_data)?; 63 | 64 | if member.pool.ne(pool) { 65 | return Err(ProgramError::InvalidAccountData); 66 | } 67 | 68 | if is_writable && !info.is_writable { 69 | return Err(ProgramError::InvalidAccountData); 70 | } 71 | 72 | Ok(()) 73 | } 74 | 75 | /// Errors if: 76 | /// - Owner is not pool program. 77 | /// - Data is empty. 78 | /// - Data cannot be deserialized into a pool account. 79 | /// - Pool authority is not expected value. 80 | /// - Expected to be writable, but is not. 81 | pub fn load_pool( 82 | info: &AccountInfo<'_>, 83 | authority: &Pubkey, 84 | is_writable: bool, 85 | ) -> Result<(), ProgramError> { 86 | if info.owner.ne(&crate::id()) { 87 | return Err(ProgramError::InvalidAccountOwner); 88 | } 89 | 90 | if info.data_is_empty() { 91 | return Err(ProgramError::UninitializedAccount); 92 | } 93 | 94 | let pool_data = info.data.borrow(); 95 | let pool = Pool::try_from_bytes(&pool_data)?; 96 | 97 | if pool.authority.ne(authority) { 98 | return Err(ProgramError::InvalidAccountData); 99 | } 100 | 101 | if is_writable && !info.is_writable { 102 | return Err(ProgramError::InvalidAccountData); 103 | } 104 | 105 | Ok(()) 106 | } 107 | 108 | /// Errors if: 109 | /// - Owner is not pool program. 110 | /// - Data is empty. 111 | /// - Account discriminator does not match expected value. 112 | /// - Expected to be writable, but is not. 113 | pub fn load_any_pool(info: &AccountInfo<'_>, is_writable: bool) -> Result<(), ProgramError> { 114 | if info.owner.ne(&crate::id()) { 115 | return Err(ProgramError::InvalidAccountOwner); 116 | } 117 | 118 | if info.data_is_empty() { 119 | return Err(ProgramError::UninitializedAccount); 120 | } 121 | 122 | if info.data.borrow()[0].ne(&(Pool::discriminator())) { 123 | return Err(solana_program::program_error::ProgramError::InvalidAccountData); 124 | } 125 | 126 | if is_writable && !info.is_writable { 127 | return Err(ProgramError::InvalidAccountData); 128 | } 129 | 130 | Ok(()) 131 | } 132 | 133 | /// Errors if: 134 | /// - Owner is not pool program. 135 | /// - Data is empty. 136 | /// - Data cannot be deserialized into a share account. 137 | /// - Share authority is not expected value. 138 | /// - Share mint account is not the expected value. 139 | /// - Expected to be writable, but is not. 140 | pub fn load_share( 141 | info: &AccountInfo<'_>, 142 | authority: &Pubkey, 143 | pool: &Pubkey, 144 | mint: &Pubkey, 145 | is_writable: bool, 146 | ) -> Result<(), ProgramError> { 147 | if info.owner.ne(&crate::id()) { 148 | return Err(ProgramError::InvalidAccountOwner); 149 | } 150 | 151 | if info.data_is_empty() { 152 | return Err(ProgramError::UninitializedAccount); 153 | } 154 | 155 | let share_data = info.data.borrow(); 156 | let share = Share::try_from_bytes(&share_data)?; 157 | 158 | if share.authority.ne(authority) { 159 | return Err(ProgramError::InvalidAccountData); 160 | } 161 | 162 | if share.pool.ne(pool) { 163 | return Err(ProgramError::InvalidAccountData); 164 | } 165 | 166 | if share.mint.ne(mint) { 167 | return Err(ProgramError::InvalidAccountData); 168 | } 169 | 170 | if is_writable && !info.is_writable { 171 | return Err(ProgramError::InvalidAccountData); 172 | } 173 | 174 | Ok(()) 175 | } 176 | -------------------------------------------------------------------------------- /api/src/sdk.rs: -------------------------------------------------------------------------------- 1 | use drillx::Solution; 2 | use ore_api::{ 3 | consts::{CONFIG_ADDRESS, MINT_ADDRESS, TREASURY_ADDRESS, TREASURY_TOKENS_ADDRESS}, 4 | state::proof_pda, 5 | }; 6 | use steel::*; 7 | 8 | use crate::{ 9 | consts::LEGACY_BOOST_PROGRAM_ID, 10 | error::ApiError, 11 | instruction::*, 12 | state::{legacy_boost_pda, legacy_stake_pda, member_pda, pool_pda, pool_proof_pda, share_pda}, 13 | }; 14 | 15 | /// Builds a launch instruction. 16 | #[allow(deprecated)] 17 | pub fn launch(signer: Pubkey, miner: Pubkey, url: String) -> Result { 18 | let url = url_to_bytes(url.as_str())?; 19 | let (pool_pda, pool_bump) = pool_pda(signer); 20 | let (proof_pda, proof_bump) = pool_proof_pda(pool_pda); 21 | let ix = Instruction { 22 | program_id: crate::ID, 23 | accounts: vec![ 24 | AccountMeta::new(signer, true), 25 | AccountMeta::new_readonly(miner, false), 26 | AccountMeta::new(pool_pda, false), 27 | AccountMeta::new(proof_pda, false), 28 | AccountMeta::new_readonly(ore_api::ID, false), 29 | AccountMeta::new_readonly(ore_boost_api::ID, false), 30 | AccountMeta::new_readonly(spl_token::ID, false), 31 | AccountMeta::new_readonly(spl_associated_token_account::ID, false), 32 | AccountMeta::new_readonly(system_program::ID, false), 33 | AccountMeta::new_readonly(sysvar::slot_hashes::ID, false), 34 | ], 35 | data: Launch { 36 | pool_bump, 37 | proof_bump, 38 | url, 39 | } 40 | .to_bytes(), 41 | }; 42 | Ok(ix) 43 | } 44 | 45 | /// Builds an join instruction. 46 | #[allow(deprecated)] 47 | pub fn join(member_authority: Pubkey, pool: Pubkey, payer: Pubkey) -> Instruction { 48 | let (member_pda, member_bump) = member_pda(member_authority, pool); 49 | Instruction { 50 | program_id: crate::ID, 51 | accounts: vec![ 52 | AccountMeta::new(payer, true), 53 | AccountMeta::new_readonly(member_authority, false), 54 | AccountMeta::new(member_pda, false), 55 | AccountMeta::new(pool, false), 56 | AccountMeta::new_readonly(system_program::ID, false), 57 | ], 58 | data: Join { member_bump }.to_bytes(), 59 | } 60 | } 61 | 62 | /// Builds a claim instruction. 63 | #[allow(deprecated)] 64 | pub fn claim( 65 | signer: Pubkey, 66 | beneficiary: Pubkey, 67 | pool_address: Pubkey, 68 | amount: u64, 69 | ) -> Instruction { 70 | let (member_pda, _) = member_pda(signer, pool_address); 71 | let (pool_proof_pda, _) = proof_pda(pool_address); 72 | let pool_tokens_address = 73 | spl_associated_token_account::get_associated_token_address(&pool_address, &MINT_ADDRESS); 74 | Instruction { 75 | program_id: crate::ID, 76 | accounts: vec![ 77 | AccountMeta::new(signer, true), 78 | AccountMeta::new(beneficiary, false), 79 | AccountMeta::new(member_pda, false), 80 | AccountMeta::new(pool_address, false), 81 | AccountMeta::new(pool_tokens_address, false), 82 | AccountMeta::new(pool_proof_pda, false), 83 | AccountMeta::new_readonly(TREASURY_ADDRESS, false), 84 | AccountMeta::new(TREASURY_TOKENS_ADDRESS, false), 85 | AccountMeta::new_readonly(ore_api::ID, false), 86 | AccountMeta::new_readonly(spl_token::ID, false), 87 | ], 88 | data: Claim { 89 | amount: amount.to_le_bytes(), 90 | pool_bump: 0, 91 | } 92 | .to_bytes(), 93 | } 94 | } 95 | 96 | /// Builds an attribute instruction. 97 | pub fn attribute(signer: Pubkey, member_authority: Pubkey, total_balance: u64) -> Instruction { 98 | let (pool_address, _) = pool_pda(signer); 99 | let (proof_address, _) = pool_proof_pda(pool_address); 100 | let (member_address, _) = member_pda(member_authority, pool_address); 101 | let pool_tokens_address = 102 | spl_associated_token_account::get_associated_token_address(&pool_address, &MINT_ADDRESS); 103 | Instruction { 104 | program_id: crate::ID, 105 | accounts: vec![ 106 | AccountMeta::new(signer, true), 107 | AccountMeta::new(pool_address, false), 108 | AccountMeta::new(pool_tokens_address, false), 109 | AccountMeta::new(proof_address, false), 110 | AccountMeta::new(member_address, false), 111 | ], 112 | data: Attribute { 113 | total_balance: total_balance.to_le_bytes(), 114 | } 115 | .to_bytes(), 116 | } 117 | } 118 | 119 | /// Builds a commit instruction. 120 | #[deprecated( 121 | since = "0.3.0", 122 | note = "Staking has moved to the global boost program" 123 | )] 124 | #[allow(deprecated)] 125 | pub fn commit(_signer: Pubkey, _mint: Pubkey) -> Instruction { 126 | panic!("Staking has moved to the global boost program"); 127 | } 128 | 129 | /// Builds an submit instruction. 130 | pub fn submit( 131 | signer: Pubkey, 132 | solution: Solution, 133 | attestation: [u8; 32], 134 | bus: Pubkey, 135 | // boost_accounts: Option<[Pubkey; 3]>, 136 | ) -> Instruction { 137 | let (pool_pda, _) = pool_pda(signer); 138 | let (proof_pda, _) = pool_proof_pda(pool_pda); 139 | let (boost_config, _) = ore_boost_api::state::config_pda(); 140 | let (boost_proof, _) = ore_api::state::proof_pda(boost_config); 141 | let accounts = vec![ 142 | AccountMeta::new(signer, true), 143 | AccountMeta::new(bus, false), 144 | AccountMeta::new_readonly(CONFIG_ADDRESS, false), 145 | AccountMeta::new(pool_pda, false), 146 | AccountMeta::new(proof_pda, false), 147 | AccountMeta::new_readonly(ore_api::ID, false), 148 | AccountMeta::new_readonly(system_program::ID, false), 149 | AccountMeta::new_readonly(sysvar::instructions::ID, false), 150 | AccountMeta::new_readonly(sysvar::slot_hashes::ID, false), 151 | AccountMeta::new_readonly(boost_config, false), 152 | AccountMeta::new(boost_proof, false), 153 | ]; 154 | Instruction { 155 | program_id: crate::ID, 156 | accounts, 157 | data: Submit { 158 | attestation, 159 | digest: solution.d, 160 | nonce: solution.n, 161 | } 162 | .to_bytes(), 163 | } 164 | } 165 | 166 | /// Builds an unstake instruction for legacy boost program. 167 | pub fn unstake( 168 | signer: Pubkey, 169 | mint: Pubkey, 170 | pool: Pubkey, 171 | recipient: Pubkey, 172 | amount: u64, 173 | ) -> Instruction { 174 | let (boost_pda, _) = legacy_boost_pda(mint); 175 | let boost_tokens = 176 | spl_associated_token_account::get_associated_token_address(&boost_pda, &mint); 177 | let (member_pda, _) = member_pda(signer, pool); 178 | let pool_tokens = spl_associated_token_account::get_associated_token_address(&pool, &mint); 179 | let (share_pda, _) = share_pda(signer, pool, mint); 180 | let (stake_pda, _) = legacy_stake_pda(pool, boost_pda); 181 | Instruction { 182 | program_id: crate::ID, 183 | accounts: vec![ 184 | AccountMeta::new(signer, true), 185 | AccountMeta::new(boost_pda, false), 186 | AccountMeta::new(boost_tokens, false), 187 | AccountMeta::new_readonly(mint, false), 188 | AccountMeta::new_readonly(member_pda, false), 189 | AccountMeta::new(pool, false), 190 | AccountMeta::new(pool_tokens, false), 191 | AccountMeta::new(recipient, false), 192 | AccountMeta::new(share_pda, false), 193 | AccountMeta::new(stake_pda, false), 194 | AccountMeta::new_readonly(spl_token::ID, false), 195 | AccountMeta::new_readonly(LEGACY_BOOST_PROGRAM_ID, false), 196 | ], 197 | data: Unstake { 198 | amount: amount.to_le_bytes(), 199 | } 200 | .to_bytes(), 201 | } 202 | } 203 | 204 | /// builds a stake instruction. 205 | #[deprecated( 206 | since = "0.3.0", 207 | note = "Staking has moved to the global boost program" 208 | )] 209 | #[allow(deprecated)] 210 | pub fn stake( 211 | signer: Pubkey, 212 | mint: Pubkey, 213 | pool: Pubkey, 214 | sender: Pubkey, 215 | amount: u64, 216 | ) -> Instruction { 217 | let (member_pda, _) = member_pda(signer, pool); 218 | let pool_tokens = spl_associated_token_account::get_associated_token_address(&pool, &mint); 219 | let (share_pda, _) = share_pda(signer, pool, mint); 220 | Instruction { 221 | program_id: crate::ID, 222 | accounts: vec![ 223 | AccountMeta::new(signer, true), 224 | AccountMeta::new_readonly(mint, false), 225 | AccountMeta::new_readonly(member_pda, false), 226 | AccountMeta::new_readonly(pool, false), 227 | AccountMeta::new(pool_tokens, false), 228 | AccountMeta::new(sender, false), 229 | AccountMeta::new(share_pda, false), 230 | AccountMeta::new_readonly(spl_token::ID, false), 231 | ], 232 | data: Stake { 233 | amount: amount.to_le_bytes(), 234 | } 235 | .to_bytes(), 236 | } 237 | } 238 | 239 | /// Builds an open share instruction. 240 | #[deprecated( 241 | since = "0.3.0", 242 | note = "Staking has moved to the global boost program" 243 | )] 244 | #[allow(deprecated)] 245 | pub fn open_share(_signer: Pubkey, _mint: Pubkey, _pool: Pubkey) -> Instruction { 246 | panic!("Staking has moved to the global boost program"); 247 | } 248 | 249 | /// Builds an open stake instruction. 250 | #[deprecated( 251 | since = "0.3.0", 252 | note = "Staking has moved to the global boost program" 253 | )] 254 | #[allow(deprecated)] 255 | pub fn open_stake(_signer: Pubkey, _mint: Pubkey) -> Instruction { 256 | panic!("Staking has moved to the global boost program"); 257 | } 258 | 259 | fn url_to_bytes(input: &str) -> Result<[u8; 128], ApiError> { 260 | let bytes = input.as_bytes(); 261 | let len = bytes.len(); 262 | if len > 128 { 263 | Err(ApiError::UrlTooLarge) 264 | } else { 265 | let mut array = [0u8; 128]; 266 | array[..len].copy_from_slice(&bytes[..len]); 267 | Ok(array) 268 | } 269 | } 270 | -------------------------------------------------------------------------------- /api/src/state/member.rs: -------------------------------------------------------------------------------- 1 | use steel::*; 2 | 3 | use super::AccountDiscriminator; 4 | 5 | /// Member records the participant's claimable balance in the mining pool. 6 | #[repr(C)] 7 | #[derive(Clone, Copy, Debug, PartialEq, Pod, Zeroable)] 8 | pub struct Member { 9 | /// The member id. 10 | pub id: u64, 11 | 12 | /// The pool this member belongs to. 13 | pub pool: Pubkey, 14 | 15 | /// The authority allowed to claim this balance. 16 | pub authority: Pubkey, 17 | 18 | /// The current balance amount which may be claimed. 19 | pub balance: u64, 20 | 21 | /// The total balance this member has earned in the lifetime of their participation in the pool. 22 | pub total_balance: u64, 23 | } 24 | 25 | account!(AccountDiscriminator, Member); 26 | -------------------------------------------------------------------------------- /api/src/state/mod.rs: -------------------------------------------------------------------------------- 1 | mod member; 2 | mod pool; 3 | mod share; 4 | 5 | pub use member::*; 6 | pub use pool::*; 7 | pub use share::*; 8 | 9 | use steel::*; 10 | 11 | use crate::consts::*; 12 | 13 | #[repr(u8)] 14 | #[derive(Clone, Copy, Debug, Eq, PartialEq, IntoPrimitive, TryFromPrimitive)] 15 | pub enum AccountDiscriminator { 16 | Member = 100, 17 | Pool = 101, 18 | Share = 102, 19 | } 20 | 21 | pub fn pool_pda(authority: Pubkey) -> (Pubkey, u8) { 22 | Pubkey::find_program_address(&[POOL, authority.as_ref()], &crate::id()) 23 | } 24 | 25 | pub fn pool_proof_pda(pool: Pubkey) -> (Pubkey, u8) { 26 | Pubkey::find_program_address(&[ore_api::consts::PROOF, pool.as_ref()], &ore_api::id()) 27 | } 28 | 29 | pub fn pool_pending_stake_token_address(pool: Pubkey, mint: Pubkey) -> Pubkey { 30 | spl_associated_token_account::get_associated_token_address(&pool, &mint) 31 | } 32 | 33 | pub fn pool_stake_pda(pool: Pubkey, mint: Pubkey) -> (Pubkey, u8) { 34 | let boost_pda = ore_boost_api::state::boost_pda(mint); 35 | ore_boost_api::state::stake_pda(pool, boost_pda.0) 36 | } 37 | 38 | pub fn member_pda(authority: Pubkey, pool: Pubkey) -> (Pubkey, u8) { 39 | Pubkey::find_program_address(&[MEMBER, authority.as_ref(), pool.as_ref()], &crate::id()) 40 | } 41 | 42 | pub fn share_pda(authority: Pubkey, pool: Pubkey, mint: Pubkey) -> (Pubkey, u8) { 43 | Pubkey::find_program_address( 44 | &[SHARE, authority.as_ref(), pool.as_ref(), mint.as_ref()], 45 | &crate::id(), 46 | ) 47 | } 48 | 49 | /// Legacy boost PDAs 50 | pub fn legacy_boost_pda(mint: Pubkey) -> (Pubkey, u8) { 51 | Pubkey::find_program_address( 52 | &[ore_boost_api::consts::BOOST, mint.as_ref()], 53 | &LEGACY_BOOST_PROGRAM_ID, 54 | ) 55 | } 56 | 57 | pub fn legacy_stake_pda(authority: Pubkey, boost: Pubkey) -> (Pubkey, u8) { 58 | Pubkey::find_program_address( 59 | &[ 60 | ore_boost_api::consts::STAKE, 61 | authority.as_ref(), 62 | boost.as_ref(), 63 | ], 64 | &LEGACY_BOOST_PROGRAM_ID, 65 | ) 66 | } 67 | -------------------------------------------------------------------------------- /api/src/state/pool.rs: -------------------------------------------------------------------------------- 1 | use steel::*; 2 | 3 | use super::AccountDiscriminator; 4 | 5 | /// Pool tracks global lifetime stats about the mining pool. 6 | #[repr(C)] 7 | #[derive(Clone, Copy, Debug, PartialEq, Pod, Zeroable)] 8 | pub struct Pool { 9 | /// The authority of this pool. 10 | pub authority: Pubkey, 11 | 12 | /// The bump used for signing CPIs. 13 | #[deprecated(since = "0.1.3", note = "Bumps are no longer required")] 14 | pub bump: u64, 15 | 16 | /// The url where hashes should be submitted (right padded with 0s). 17 | pub url: [u8; 128], 18 | 19 | /// The latest attestation posted by this pool operator. 20 | pub attestation: [u8; 32], 21 | 22 | /// Foreign key to the ORE proof account. 23 | pub last_hash_at: i64, 24 | 25 | /// The net sum of rewards claimable by members in the pool. 26 | pub total_rewards: u64, 27 | 28 | /// The total number of hashes this pool has submitted. 29 | pub total_submissions: u64, 30 | 31 | /// The total number of members in this pool. 32 | pub total_members: u64, 33 | 34 | /// The total number of members in this pool at the last submission. 35 | pub last_total_members: u64, 36 | } 37 | 38 | account!(AccountDiscriminator, Pool); 39 | -------------------------------------------------------------------------------- /api/src/state/share.rs: -------------------------------------------------------------------------------- 1 | use steel::*; 2 | 3 | use super::AccountDiscriminator; 4 | 5 | /// Share tracks a member's contribution to the pool stake account. 6 | #[repr(C)] 7 | #[derive(Clone, Copy, Debug, PartialEq, Pod, Zeroable)] 8 | pub struct Share { 9 | /// The authority of this share account. 10 | pub authority: Pubkey, 11 | 12 | /// The stake balance the authority has deposited and may unstake. 13 | pub balance: u64, 14 | 15 | /// The mint this share account is associated with. 16 | pub mint: Pubkey, 17 | 18 | /// The pool this share account is associated with. 19 | pub pool: Pubkey, 20 | } 21 | 22 | account!(AccountDiscriminator, Share); 23 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.1' # Use version 3.1 or newer 2 | 3 | services: 4 | db: 5 | image: postgres:latest # Use the latest official PostgreSQL image 6 | restart: always 7 | environment: 8 | POSTGRES_PASSWORD: password # Set the default password for the 'postgres' user 9 | POSTGRES_USER: postgres # (Optional) Define a custom user instead of the default 'postgres' 10 | POSTGRES_DB: pooldb # (Optional) Specify a database to be automatically created on first run 11 | ports: 12 | - "5432:5432" # Map the container port 5432 to the host 13 | volumes: 14 | - ./init-db:/docker-entrypoint-initdb.d 15 | - pooldb:/var/lib/postgresql/data # Persist database data 16 | 17 | volumes: 18 | pooldb: # Define the volume name used above 19 | -------------------------------------------------------------------------------- /init-db/01_setup.sql: -------------------------------------------------------------------------------- 1 | -- create members table 2 | DO $$ 3 | BEGIN 4 | IF NOT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'members') THEN 5 | CREATE TABLE members ( 6 | address VARCHAR PRIMARY KEY, 7 | id BIGINT NOT NULL, 8 | authority VARCHAR NOT NULL, 9 | pool_address VARCHAR NOT NULL, 10 | total_balance BIGINT NOT NULL, 11 | is_approved BOOLEAN NOT NULL, 12 | is_kyc BOOLEAN NOT NULL, 13 | is_synced BOOLEAN NOT NULL, 14 | CONSTRAINT unique_member_id UNIQUE (id) 15 | ); 16 | END IF; 17 | END 18 | $$; 19 | 20 | -- create index on members authority 21 | DO $$ 22 | BEGIN 23 | IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'members_authority_idx') THEN 24 | CREATE INDEX members_authority_idx ON members(authority); 25 | END IF; 26 | END 27 | $$; 28 | -------------------------------------------------------------------------------- /program/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "ore-pool-program" 3 | description = "A program to coordinate ORE mining pools" 4 | version.workspace = true 5 | edition.workspace = true 6 | license.workspace = true 7 | homepage.workspace = true 8 | documentation.workspace = true 9 | repository.workspace = true 10 | readme.workspace = true 11 | keywords.workspace = true 12 | 13 | [lib] 14 | crate-type = ["cdylib", "lib"] 15 | 16 | [features] 17 | default = [] 18 | 19 | [dependencies] 20 | bytemuck.workspace = true 21 | drillx.workspace = true 22 | ore-api.workspace = true 23 | ore-boost-api.workspace = true 24 | ore-pool-api.workspace = true 25 | solana-program.workspace = true 26 | spl-token.workspace = true 27 | spl-associated-token-account.workspace = true 28 | steel.workspace = true 29 | 30 | [dev-dependencies] 31 | rand = { workspace = true } 32 | -------------------------------------------------------------------------------- /program/src/attribute.rs: -------------------------------------------------------------------------------- 1 | use ore_api::prelude::*; 2 | use ore_pool_api::prelude::*; 3 | use steel::*; 4 | 5 | /// Attribute updates a member's claimable balance. 6 | /// 7 | /// The arguments to this function expect the member's lifetime earnings. This way, 8 | /// the balance can be updated idempotently and duplicate transactions will not result in 9 | /// duplicate earnings. 10 | pub fn process_attribute(accounts: &[AccountInfo<'_>], data: &[u8]) -> ProgramResult { 11 | // Parse args. 12 | let args = Attribute::try_from_bytes(data)?; 13 | let total_balance = u64::from_le_bytes(args.total_balance); 14 | 15 | // Load accounts. 16 | let [signer_info, pool_info, pool_tokens_info, proof_info, member_info] = accounts else { 17 | return Err(ProgramError::NotEnoughAccountKeys); 18 | }; 19 | signer_info.is_signer()?; 20 | let pool = pool_info 21 | .as_account_mut::(&ore_pool_api::ID)? 22 | .assert_mut(|p| p.authority == *signer_info.key)?; 23 | let proof = proof_info 24 | .as_account::(&ore_api::ID)? 25 | .assert(|p| p.authority == *pool_info.key)?; 26 | let member = member_info 27 | .as_account_mut::(&ore_pool_api::ID)? 28 | .assert_mut(|m| m.pool == *pool_info.key)? 29 | .assert_mut(|m| total_balance >= m.total_balance)?; 30 | 31 | // Update balance idempotently 32 | let balance_change = total_balance - member.total_balance; 33 | member.balance += balance_change; 34 | member.total_balance = total_balance; 35 | 36 | // Update claimable balance 37 | pool.total_rewards += balance_change; 38 | 39 | // Calculate the total reserves of the pool. 40 | let reserves = if pool_tokens_info.data_is_empty() { 41 | proof.balance 42 | } else { 43 | let pool_tokens = 44 | pool_tokens_info.as_associated_token_account(pool_info.key, &MINT_ADDRESS)?; 45 | proof.balance + pool_tokens.amount() 46 | }; 47 | 48 | // Validate there are enough reserves to cover the total rewards owed to miners. 49 | // This protects pool members from the scenario of a malicious pool operator or compromised key 50 | // stealing previously attributed member rewards. 51 | if pool.total_rewards > reserves { 52 | return Err(PoolError::AttributionTooLarge.into()); 53 | } 54 | 55 | Ok(()) 56 | } 57 | -------------------------------------------------------------------------------- /program/src/claim.rs: -------------------------------------------------------------------------------- 1 | use ore_api::prelude::*; 2 | use ore_pool_api::prelude::*; 3 | use steel::*; 4 | 5 | /// Claim allows a member to claim their ORE rewards from the pool. 6 | pub fn process_claim(accounts: &[AccountInfo<'_>], data: &[u8]) -> ProgramResult { 7 | // Parse args. 8 | let args = ore_pool_api::instruction::Claim::try_from_bytes(data)?; 9 | let amount = u64::from_le_bytes(args.amount); 10 | 11 | // Load accounts. 12 | let [signer_info, beneficiary_info, member_info, pool_info, pool_tokens_info, proof_info, treasury_info, treasury_tokens_info, ore_program, token_program] = 13 | accounts 14 | else { 15 | return Err(ProgramError::NotEnoughAccountKeys); 16 | }; 17 | signer_info.is_signer()?; 18 | beneficiary_info 19 | .is_writable()? 20 | .as_token_account()? 21 | .assert(|t| t.mint() == MINT_ADDRESS)?; 22 | let member: &mut Member = member_info 23 | .as_account_mut::(&ore_pool_api::ID)? 24 | .assert_mut(|m| m.authority == *signer_info.key)? 25 | .assert_mut(|m| m.pool == *pool_info.key)?; 26 | let pool = pool_info.as_account_mut::(&ore_pool_api::ID)?; 27 | proof_info 28 | .as_account::(&ore_api::ID)? 29 | .assert(|p| p.authority == *pool_info.key)?; 30 | treasury_info.has_address(&ore_api::consts::TREASURY_ADDRESS)?; 31 | treasury_tokens_info.has_address(&ore_api::consts::TREASURY_TOKENS_ADDRESS)?; 32 | ore_program.is_program(&ore_api::ID)?; 33 | token_program.is_program(&spl_token::ID)?; 34 | 35 | // Update member balance 36 | member.balance -= amount; 37 | 38 | // Update pool balance 39 | pool.total_rewards -= amount; 40 | 41 | // Claim first from pool tokens 42 | let mut amount_claimed = 0; 43 | if !pool_tokens_info.data_is_empty() { 44 | // Verify pool tokens account 45 | let pool_tokens = pool_tokens_info 46 | .is_writable()? 47 | .as_associated_token_account(pool_info.key, &MINT_ADDRESS)?; 48 | 49 | // Calculate how much we can claim from pool tokens 50 | amount_claimed = pool_tokens.amount().min(amount); 51 | 52 | // Transfer available tokens from pool to beneficiary 53 | if amount_claimed > 0 { 54 | transfer_signed( 55 | pool_info, 56 | pool_tokens_info, 57 | beneficiary_info, 58 | token_program, 59 | amount_claimed, 60 | &[POOL, pool.authority.as_ref()], 61 | )?; 62 | } 63 | } 64 | 65 | // If we still have tokens to claim after claiming from pool tokens, claim from ore program 66 | let remaining_amount = amount - amount_claimed; 67 | if remaining_amount > 0 { 68 | invoke_signed( 69 | &ore_api::sdk::claim(*pool_info.key, *beneficiary_info.key, remaining_amount), 70 | &[ 71 | pool_info.clone(), 72 | beneficiary_info.clone(), 73 | proof_info.clone(), 74 | treasury_info.clone(), 75 | treasury_tokens_info.clone(), 76 | token_program.clone(), 77 | ], 78 | &ore_pool_api::ID, 79 | &[POOL, pool.authority.as_ref()], 80 | )?; 81 | } 82 | 83 | Ok(()) 84 | } 85 | -------------------------------------------------------------------------------- /program/src/commit.rs: -------------------------------------------------------------------------------- 1 | use ore_pool_api::prelude::*; 2 | use steel::*; 3 | 4 | /// Commit pending stake from the pool program into the boost program. 5 | pub fn process_commit(_accounts: &[AccountInfo<'_>], _data: &[u8]) -> ProgramResult { 6 | Err(PoolError::WithdrawOnlyMode.into()) 7 | } 8 | -------------------------------------------------------------------------------- /program/src/join.rs: -------------------------------------------------------------------------------- 1 | use ore_pool_api::prelude::*; 2 | use steel::*; 3 | 4 | /// Join creates a new account for a pool participant. 5 | pub fn process_join(accounts: &[AccountInfo<'_>], _data: &[u8]) -> ProgramResult { 6 | // Load accounts. 7 | let [signer_info, member_authority_info, member_info, pool_info, system_program] = accounts 8 | else { 9 | return Err(ProgramError::NotEnoughAccountKeys); 10 | }; 11 | signer_info.is_signer()?; 12 | member_info.is_empty()?.is_writable()?.has_seeds( 13 | &[ 14 | MEMBER, 15 | member_authority_info.key.as_ref(), 16 | pool_info.key.as_ref(), 17 | ], 18 | &ore_pool_api::ID, 19 | )?; 20 | let pool = pool_info.as_account_mut::(&ore_pool_api::ID)?; 21 | system_program.is_program(&system_program::ID)?; 22 | 23 | // Initialize member account 24 | create_program_account::( 25 | member_info, 26 | system_program, 27 | signer_info, 28 | &ore_pool_api::ID, 29 | &[ 30 | MEMBER, 31 | member_authority_info.key.as_ref(), 32 | pool_info.key.as_ref(), 33 | ], 34 | )?; 35 | 36 | // Init member 37 | let member = member_info.as_account_mut::(&ore_pool_api::ID)?; 38 | member.authority = *member_authority_info.key; 39 | member.balance = 0; 40 | member.total_balance = 0; 41 | member.pool = *pool_info.key; 42 | member.id = pool.total_members; // zero index 43 | 44 | // Update total pool member count. 45 | pool.total_members = pool.total_members.checked_add(1).unwrap(); 46 | 47 | Ok(()) 48 | } 49 | -------------------------------------------------------------------------------- /program/src/launch.rs: -------------------------------------------------------------------------------- 1 | use ore_api::prelude::*; 2 | use ore_pool_api::prelude::*; 3 | use steel::*; 4 | 5 | /// Launch creates a new pool. 6 | pub fn process_launch(accounts: &[AccountInfo<'_>], data: &[u8]) -> ProgramResult { 7 | // Parse args. 8 | let args = Launch::try_from_bytes(data)?; 9 | 10 | // Load accounts. 11 | let [signer_info, miner_info, pool_info, proof_info, ore_program, ore_boost_program, token_program, associated_token_program, system_program, slot_hashes_sysvar] = 12 | accounts 13 | else { 14 | return Err(ProgramError::NotEnoughAccountKeys); 15 | }; 16 | signer_info.is_signer()?; 17 | pool_info 18 | .is_writable()? 19 | .has_seeds(&[POOL, signer_info.key.as_ref()], &ore_pool_api::ID)?; 20 | proof_info 21 | .is_writable()? 22 | .has_seeds(&[PROOF, pool_info.key.as_ref()], &ore_api::ID)?; 23 | ore_program.is_program(&ore_api::ID)?; 24 | ore_boost_program.is_program(&ore_boost_api::ID)?; 25 | token_program.is_program(&spl_token::ID)?; 26 | associated_token_program.is_program(&spl_associated_token_account::ID)?; 27 | system_program.is_program(&system_program::ID)?; 28 | slot_hashes_sysvar.is_sysvar(&sysvar::slot_hashes::ID)?; 29 | 30 | // Open proof account. 31 | if proof_info.is_empty().is_ok() { 32 | invoke_signed( 33 | &ore_api::sdk::open(*pool_info.key, *miner_info.key, *signer_info.key), 34 | &[ 35 | pool_info.clone(), 36 | miner_info.clone(), 37 | signer_info.clone(), 38 | proof_info.clone(), 39 | system_program.clone(), 40 | slot_hashes_sysvar.clone(), 41 | ], 42 | &ore_pool_api::ID, 43 | &[POOL, signer_info.key.as_ref()], 44 | )?; 45 | } 46 | 47 | // Initialize pool account. 48 | let proof = proof_info.as_account::(&ore_api::ID)?; 49 | if pool_info.is_empty().is_ok() { 50 | create_program_account::( 51 | pool_info, 52 | system_program, 53 | signer_info, 54 | &ore_pool_api::id(), 55 | &[POOL, signer_info.key.as_ref()], 56 | )?; 57 | let pool = pool_info.as_account_mut::(&ore_pool_api::ID)?; 58 | pool.attestation = [0; 32]; 59 | pool.authority = *signer_info.key; 60 | pool.last_total_members = 0; 61 | pool.last_hash_at = proof.last_hash_at; 62 | pool.url = args.url; 63 | } 64 | 65 | Ok(()) 66 | } 67 | -------------------------------------------------------------------------------- /program/src/lib.rs: -------------------------------------------------------------------------------- 1 | mod attribute; 2 | mod claim; 3 | mod commit; 4 | mod join; 5 | mod launch; 6 | mod open_share; 7 | mod open_stake; 8 | mod stake; 9 | mod submit; 10 | mod unstake; 11 | 12 | use attribute::*; 13 | use claim::*; 14 | use commit::*; 15 | use join::*; 16 | use launch::*; 17 | use open_share::*; 18 | use open_stake::*; 19 | use stake::*; 20 | use submit::*; 21 | use unstake::*; 22 | 23 | use ore_pool_api::prelude::*; 24 | use steel::*; 25 | 26 | #[allow(deprecated)] 27 | pub fn process_instruction( 28 | program_id: &Pubkey, 29 | accounts: &[AccountInfo], 30 | data: &[u8], 31 | ) -> ProgramResult { 32 | let (ix, data) = parse_instruction(&ore_pool_api::ID, program_id, data)?; 33 | match ix { 34 | // User 35 | PoolInstruction::Join => process_join(accounts, data)?, 36 | PoolInstruction::Claim => process_claim(accounts, data)?, 37 | PoolInstruction::OpenShare => process_open_share(accounts, data)?, 38 | PoolInstruction::Stake => process_stake(accounts, data)?, 39 | PoolInstruction::Unstake => process_unstake(accounts, data)?, 40 | 41 | // Admin 42 | PoolInstruction::Attribute => process_attribute(accounts, data)?, 43 | PoolInstruction::Commit => process_commit(accounts, data)?, 44 | PoolInstruction::Launch => process_launch(accounts, data)?, 45 | PoolInstruction::OpenStake => process_open_stake(accounts, data)?, 46 | PoolInstruction::Submit => process_submit(accounts, data)?, 47 | 48 | _ => panic!("Temporarily disabled for migration."), 49 | } 50 | Ok(()) 51 | } 52 | 53 | entrypoint!(process_instruction); 54 | -------------------------------------------------------------------------------- /program/src/open_share.rs: -------------------------------------------------------------------------------- 1 | use ore_pool_api::prelude::*; 2 | use steel::*; 3 | 4 | /// Opens a new share account for pool member to deposit stake. 5 | pub fn process_open_share(_accounts: &[AccountInfo<'_>], _data: &[u8]) -> ProgramResult { 6 | Err(PoolError::WithdrawOnlyMode.into()) 7 | } 8 | -------------------------------------------------------------------------------- /program/src/open_stake.rs: -------------------------------------------------------------------------------- 1 | use ore_pool_api::prelude::*; 2 | use steel::*; 3 | 4 | /// Opens a new stake account for the pool in the boost program. 5 | pub fn process_open_stake(_accounts: &[AccountInfo<'_>], _data: &[u8]) -> ProgramResult { 6 | Err(PoolError::WithdrawOnlyMode.into()) 7 | } 8 | -------------------------------------------------------------------------------- /program/src/stake.rs: -------------------------------------------------------------------------------- 1 | use ore_pool_api::prelude::*; 2 | use steel::*; 3 | 4 | /// Deposit tokens into a pool's pending stake account. 5 | pub fn process_stake(_accounts: &[AccountInfo<'_>], _data: &[u8]) -> ProgramResult { 6 | Err(PoolError::WithdrawOnlyMode.into()) 7 | } 8 | -------------------------------------------------------------------------------- /program/src/submit.rs: -------------------------------------------------------------------------------- 1 | use drillx::Solution; 2 | use ore_api::prelude::*; 3 | use ore_pool_api::prelude::*; 4 | use steel::*; 5 | 6 | /// Submit sends the pool's best hash to the ORE mining contract. 7 | pub fn process_submit(accounts: &[AccountInfo<'_>], data: &[u8]) -> ProgramResult { 8 | // Parse args. 9 | let args = Submit::try_from_bytes(data)?; 10 | 11 | // Load accounts. 12 | let (required_accounts, boost_accounts) = accounts.split_at(9); 13 | let [signer_info, bus_info, config_info, pool_info, proof_info, ore_program, system_program, instructions_sysvar, slot_hashes_sysvar] = 14 | required_accounts 15 | else { 16 | return Err(ProgramError::NotEnoughAccountKeys); 17 | }; 18 | signer_info.is_signer()?; 19 | let pool = pool_info 20 | .as_account_mut::(&ore_pool_api::ID)? 21 | .assert_mut(|p| p.authority == *signer_info.key)?; 22 | let proof = proof_info 23 | .is_writable()? 24 | .as_account::(&ore_api::ID)? 25 | .assert(|p| p.authority == *pool_info.key)?; 26 | ore_program.is_program(&ore_api::ID)?; 27 | system_program.is_program(&system_program::ID)?; 28 | instructions_sysvar.is_sysvar(&sysvar::instructions::ID)?; 29 | slot_hashes_sysvar.is_sysvar(&sysvar::slot_hashes::ID)?; 30 | 31 | // Parse boost accounts 32 | let [boost_config, boost_proof] = boost_accounts else { 33 | return Err(ProgramError::NotEnoughAccountKeys); 34 | }; 35 | 36 | // Build solution for submitting to the ORE program 37 | let solution = Solution::new(args.digest, args.nonce); 38 | 39 | // Invoke mine CPI 40 | solana_program::program::invoke( 41 | &ore_api::sdk::mine( 42 | *signer_info.key, 43 | *pool_info.key, 44 | *bus_info.key, 45 | solution, 46 | *boost_config.key, 47 | ), 48 | &[ 49 | signer_info.clone(), 50 | bus_info.clone(), 51 | config_info.clone(), 52 | proof_info.clone(), 53 | instructions_sysvar.clone(), 54 | slot_hashes_sysvar.clone(), 55 | boost_config.clone(), 56 | boost_proof.clone(), 57 | ], 58 | )?; 59 | 60 | // Update pool state. 61 | pool.attestation = args.attestation; 62 | pool.last_hash_at = proof.last_hash_at; 63 | pool.last_total_members = pool.total_members; 64 | pool.total_submissions += 1; 65 | 66 | Ok(()) 67 | } 68 | -------------------------------------------------------------------------------- /program/src/unstake.rs: -------------------------------------------------------------------------------- 1 | use ore_pool_api::prelude::*; 2 | use solana_program::log::sol_log_data; 3 | use steel::*; 4 | 5 | /// Unstake tokens from the pool's legacy stake account. 6 | pub fn process_unstake(accounts: &[AccountInfo<'_>], data: &[u8]) -> ProgramResult { 7 | // Parse args. 8 | let args = ore_pool_api::instruction::Unstake::try_from_bytes(data)?; 9 | let amount = u64::from_le_bytes(args.amount); 10 | 11 | // Load accounts. 12 | let [signer_info, boost_info, boost_tokens_info, mint_info, member_info, pool_info, pool_tokens_info, recipient_tokens_info, share_info, stake_info, token_program, ore_boost_program] = 13 | accounts 14 | else { 15 | return Err(ProgramError::NotEnoughAccountKeys); 16 | }; 17 | signer_info.is_signer()?; 18 | boost_info 19 | .is_writable()? 20 | .has_owner(&LEGACY_BOOST_PROGRAM_ID)?; 21 | boost_tokens_info 22 | .is_writable()? 23 | .as_associated_token_account(boost_info.key, mint_info.key)?; 24 | mint_info.as_mint()?; 25 | member_info 26 | .as_account::(&ore_pool_api::ID)? 27 | .assert(|m| m.authority == *signer_info.key)? 28 | .assert(|m| m.pool == *pool_info.key)?; 29 | let pool = pool_info.as_account::(&ore_pool_api::ID)?; 30 | let pool_tokens = pool_tokens_info 31 | .is_writable()? 32 | .as_associated_token_account(pool_info.key, mint_info.key)?; 33 | recipient_tokens_info 34 | .is_writable()? 35 | .as_token_account()? 36 | .assert(|t| t.mint() == *mint_info.key)?; 37 | stake_info 38 | .is_writable()? 39 | .has_owner(&LEGACY_BOOST_PROGRAM_ID)?; 40 | let share = share_info 41 | .as_account_mut::(&ore_pool_api::ID)? 42 | .assert_mut(|s| s.authority == *signer_info.key)? 43 | .assert_mut(|s| s.pool == *pool_info.key)? 44 | .assert_mut(|s| s.mint == *mint_info.key)?; 45 | token_program.is_program(&spl_token::ID)?; 46 | ore_boost_program.is_program(&LEGACY_BOOST_PROGRAM_ID)?; 47 | 48 | // Update the share balance. 49 | share.balance = share.balance.checked_sub(amount).unwrap(); 50 | 51 | // Check how many pending tokens can be distributed back to staker. 52 | let pending_amount = pool_tokens.amount().min(amount); 53 | let withdraw_amount = amount.checked_sub(pending_amount).unwrap(); 54 | 55 | // Withdraw remaining amount from staked balance. 56 | if withdraw_amount.gt(&0) { 57 | invoke_signed( 58 | &Instruction { 59 | program_id: LEGACY_BOOST_PROGRAM_ID, 60 | accounts: vec![ 61 | AccountMeta::new(*pool_info.key, true), 62 | AccountMeta::new(*pool_tokens_info.key, false), 63 | AccountMeta::new(*boost_info.key, false), 64 | AccountMeta::new(*boost_tokens_info.key, false), 65 | AccountMeta::new_readonly(*mint_info.key, false), 66 | AccountMeta::new(*stake_info.key, false), 67 | AccountMeta::new_readonly(*token_program.key, false), 68 | ], 69 | data: [ 70 | [3 as u8].to_vec(), 71 | bytemuck::bytes_of(&withdraw_amount).to_vec(), 72 | ] 73 | .concat(), 74 | }, 75 | &[ 76 | pool_info.clone(), 77 | pool_tokens_info.clone(), 78 | boost_info.clone(), 79 | boost_tokens_info.clone(), 80 | mint_info.clone(), 81 | stake_info.clone(), 82 | token_program.clone(), 83 | ], 84 | &ore_pool_api::ID, 85 | &[POOL, pool.authority.as_ref()], 86 | )?; 87 | } 88 | 89 | // Transfer tokens into pool's pending stake account. 90 | transfer_signed( 91 | pool_info, 92 | pool_tokens_info, 93 | recipient_tokens_info, 94 | token_program, 95 | amount, 96 | &[POOL, pool.authority.as_ref()], 97 | )?; 98 | 99 | // Log the balance for parsing. 100 | let event = UnstakeEvent { 101 | authority: *signer_info.key, 102 | share: *share_info.key, 103 | mint: *mint_info.key, 104 | balance: share.balance, 105 | }; 106 | sol_log_data(&[event.to_bytes()]); 107 | 108 | Ok(()) 109 | } 110 | -------------------------------------------------------------------------------- /rust-toolchain.toml: -------------------------------------------------------------------------------- 1 | [toolchain] 2 | channel = "1.82.0" 3 | components = [ "rustfmt", "rust-analyzer" ] 4 | targets = [ "x86_64-apple-darwin", "x86_64-unknown-linux-gnu", "aarch64-apple-darwin"] 5 | profile = "minimal" 6 | -------------------------------------------------------------------------------- /server/.env.example: -------------------------------------------------------------------------------- 1 | KEYPAIR_PATH="/etc/secrets/ore-pool-authority.json" 2 | DB_URL="" 3 | RPC_URL="" 4 | ATTR_EPOCH="" // how often the attribution loop submits (in minutes) 5 | HELIUS_AUTH_TOKEN="" // auth header token we give to helius to write webhook POST events 6 | OPERATOR_COMMISSION="" // the operator commission as a percentage denoted as an integer (ex. 5 is 5%) 7 | -------------------------------------------------------------------------------- /server/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "ore-pool-server" 3 | version = "0.0.1" 4 | edition = "2021" 5 | 6 | [dependencies] 7 | actix-cors = { workspace = true } 8 | actix-web = { workspace = true } 9 | base64 = { workspace = true } 10 | bincode = { workspace = true } 11 | bytemuck = { workspace = true } 12 | cached = { workspace = true } 13 | deadpool-postgres = { workspace = true } 14 | drillx = { workspace = true } 15 | env_logger = { workspace = true } 16 | futures = { workspace = true } 17 | futures-channel = { workspace = true } 18 | futures-util = { workspace = true } 19 | log = { workspace = true } 20 | ore-api = { workspace = true } 21 | ore-boost-api = { workspace = true } 22 | ore-pool-api = { workspace = true } 23 | ore-pool-types = { workspace = true } 24 | postgres-types = { workspace = true } 25 | reqwest = { workspace = true } 26 | serde = { workspace = true } 27 | serde_json = { workspace = true } 28 | sha3 = { workspace = true } 29 | solana-client = { workspace = true } 30 | solana-sdk = { workspace = true } 31 | solana-transaction-status = { workspace = true } 32 | spl-associated-token-account = { workspace = true } 33 | steel = { workspace = true } 34 | thiserror = { workspace = true } 35 | tokio = { workspace = true } 36 | tokio-postgres = { workspace = true } 37 | rand = { workspace = true } 38 | -------------------------------------------------------------------------------- /server/README.md: -------------------------------------------------------------------------------- 1 | # ORE Mining Pool Operator Server 2 | -------------------------------------------------------------------------------- /server/src/aggregator.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | 3 | use ore_api::{ 4 | consts::{BUS_ADDRESSES, BUS_COUNT}, 5 | state::Bus, 6 | }; 7 | use ore_pool_types::Challenge; 8 | use rand::Rng; 9 | use sha3::{Digest, Sha3_256}; 10 | use solana_sdk::{pubkey::Pubkey, signer::Signer}; 11 | use steel::AccountDeserialize; 12 | 13 | use crate::{ 14 | contributions::{ 15 | Contribution, Contributions, MinerContributions, PoolMiningEvent, RecentEvents, Winner, 16 | }, 17 | database, 18 | error::Error, 19 | operator::Operator, 20 | tx, 21 | }; 22 | 23 | const MAX_DIFFICULTY: u32 = 22; 24 | const MAX_SCORE: u64 = 2u64.pow(MAX_DIFFICULTY); 25 | 26 | /// Aggregates contributions from the pool members. 27 | pub struct Aggregator { 28 | /// The current challenge. 29 | pub current_challenge: Challenge, 30 | 31 | /// The set of contributions for attribution. 32 | pub contributions: Contributions, 33 | 34 | /// The number of workers that have been approved for the current challenge. 35 | pub num_members: u64, 36 | 37 | /// The set of recent mining events. 38 | pub recent_events: RecentEvents, 39 | } 40 | 41 | pub async fn process_contributions( 42 | aggregator: &tokio::sync::RwLock, 43 | operator: &Operator, 44 | rx: &mut tokio::sync::mpsc::UnboundedReceiver, 45 | ) -> Result<(), Error> { 46 | // outer loop for new challenges 47 | loop { 48 | let timer = tokio::time::Instant::now(); 49 | let cutoff_time = { 50 | let proof = match operator.get_proof().await { 51 | Ok(proof) => proof, 52 | Err(err) => { 53 | log::error!("{:?}", err); 54 | continue; 55 | } 56 | }; 57 | match operator.get_cutoff(&proof).await { 58 | Ok(cutoff_time) => cutoff_time, 59 | Err(err) => { 60 | log::error!("{:?}", err); 61 | continue; 62 | } 63 | } 64 | }; 65 | let mut remaining_time = cutoff_time.saturating_sub(timer.elapsed().as_secs()); 66 | // inner loop to process contributions until cutoff time 67 | while remaining_time > 0 { 68 | // race the next contribution against remaining time 69 | match tokio::time::timeout(tokio::time::Duration::from_secs(remaining_time), rx.recv()) 70 | .await 71 | { 72 | Ok(Some(mut contribution)) => { 73 | { 74 | let mut aggregator = aggregator.write().await; 75 | let _ = aggregator.insert(&mut contribution); 76 | } 77 | // recalculate the remaining time after processing the contribution 78 | remaining_time = cutoff_time.saturating_sub(timer.elapsed().as_secs()); 79 | } 80 | Ok(None) => { 81 | // if the receiver is closed, exit server 82 | return Err(Error::Internal("contribution channel closed".to_string())); 83 | } 84 | Err(_) => { 85 | // timeout expired, meaning cutoff time has been reached 86 | break; 87 | } 88 | } 89 | } 90 | // at this point, the cutoff time has been reached 91 | let total_score = { 92 | let mut write = aggregator.write().await; 93 | let contributions = write.get_current_contributions(); 94 | match contributions { 95 | Ok(contributions) => contributions.total_score, 96 | Err(err) => { 97 | log::error!("{:?}", err); 98 | 0 99 | } 100 | } 101 | }; 102 | if total_score > 0 { 103 | // submit if contributions exist 104 | let mut aggregator = aggregator.write().await; 105 | if let Err(err) = aggregator.submit_and_reset(operator).await { 106 | log::error!("{:?}", err); 107 | } 108 | } else { 109 | // no contributions yet, wait for the first one to submit 110 | if let Some(mut contribution) = rx.recv().await { 111 | let mut aggregator = aggregator.write().await; 112 | let _ = aggregator.insert(&mut contribution); 113 | if let Err(err) = aggregator.submit_and_reset(operator).await { 114 | log::error!("{:?}", err); 115 | } 116 | } 117 | } 118 | } 119 | } 120 | 121 | impl Aggregator { 122 | pub async fn new(operator: &Operator) -> Result { 123 | // fetch accounts 124 | let pool = operator.get_pool().await?; 125 | let proof = operator.get_proof().await?; 126 | log::info!("proof: {:?}", proof); 127 | let cutoff_time = operator.get_cutoff(&proof).await?; 128 | let min_difficulty = operator.min_difficulty().await?; 129 | let challenge = Challenge { 130 | challenge: proof.challenge, 131 | lash_hash_at: proof.last_hash_at, 132 | min_difficulty, 133 | cutoff_time, 134 | }; 135 | 136 | // build self 137 | let mut contributions = Contributions::new(15 + 1); 138 | contributions.insert(challenge.lash_hash_at as u64); 139 | let aggregator = Aggregator { 140 | current_challenge: challenge, 141 | contributions, 142 | num_members: pool.last_total_members, 143 | recent_events: RecentEvents::new(15), 144 | }; 145 | Ok(aggregator) 146 | } 147 | 148 | fn insert(&mut self, contribution: &mut Contribution) -> Result<(), Error> { 149 | let challenge = &self.current_challenge.clone(); 150 | let solution = &contribution.solution; 151 | 152 | // normalize contribution score 153 | let normalized_score = contribution.score.min(MAX_SCORE); 154 | contribution.score = normalized_score; 155 | 156 | // get current contributions 157 | let contributions = self.get_current_contributions()?; 158 | 159 | // validate solution against current challenge 160 | if !drillx::is_valid_digest(&challenge.challenge, &solution.n, &solution.d) { 161 | log::error!("invalid solution"); 162 | return Err(Error::Internal("invalid solution".to_string())); 163 | } 164 | 165 | // insert 166 | let insert = contributions.contributions.take(contribution); 167 | match insert { 168 | Some(prev) => { 169 | // update if the new contribution score is larger 170 | if contribution.score.gt(&prev.score) { 171 | // insert new contribution 172 | contributions.contributions.insert(*contribution); 173 | 174 | // build contender (to be compared to winner) 175 | let difficulty = contribution.solution.to_hash().difficulty(); 176 | let contender = Winner { 177 | solution: contribution.solution, 178 | difficulty, 179 | }; 180 | log::info!( 181 | "updated contribution: {:?} {}", 182 | contribution.member, 183 | difficulty 184 | ); 185 | 186 | // decrement previous score 187 | contributions.total_score -= prev.score; 188 | 189 | // increment new score 190 | contributions.total_score += contribution.score; 191 | 192 | // update winner 193 | match contributions.winner { 194 | Some(winner) => { 195 | if difficulty > winner.difficulty { 196 | contributions.winner = Some(contender); 197 | } 198 | } 199 | None => contributions.winner = Some(contender), 200 | } 201 | } else { 202 | // reinsert previous contribution 203 | // as the new contribution did not improve the score 204 | contributions.contributions.insert(prev); 205 | } 206 | Ok(()) 207 | } 208 | None => { 209 | // insert contribution 210 | contributions.contributions.insert(*contribution); 211 | 212 | // build contender (to be compared to winner) 213 | let difficulty = contribution.solution.to_hash().difficulty(); 214 | let contender = Winner { 215 | solution: contribution.solution, 216 | difficulty, 217 | }; 218 | log::info!("new contribution: {:?} {}", contribution.member, difficulty); 219 | 220 | // increment score 221 | contributions.total_score += contribution.score; 222 | 223 | // update winner 224 | match contributions.winner { 225 | Some(winner) => { 226 | if difficulty > winner.difficulty { 227 | contributions.winner = Some(contender); 228 | } 229 | } 230 | None => contributions.winner = Some(contender), 231 | } 232 | Ok(()) 233 | } 234 | } 235 | } 236 | 237 | // TODO Publish block to S3 238 | async fn submit_and_reset(&mut self, operator: &Operator) -> Result<(), Error> { 239 | // check if reset is needed 240 | // this may happen if a solution is landed on chain 241 | // but a subsequent application error is thrown before resetting 242 | if self.check_for_reset(operator).await? { 243 | self.reset(operator).await?; 244 | // there was a reset 245 | // so restart contribution loop against new challenge 246 | return Ok(()); 247 | }; 248 | 249 | // prepare best solution and attestation of hash-power 250 | let winner = self.winner()?; 251 | log::info!("winner: {:?}", winner); 252 | let best_solution = winner.solution; 253 | let attestation = self.attestation()?; 254 | 255 | // derive accounts for instructions 256 | let authority = &operator.keypair.pubkey(); 257 | let (pool_address, _) = ore_pool_api::state::pool_pda(*authority); 258 | let (pool_proof_address, _) = ore_pool_api::state::pool_proof_pda(pool_address); 259 | let bus = self.find_bus(operator).await?; 260 | 261 | // build instructions 262 | let auth_ix = ore_api::sdk::auth(pool_proof_address); 263 | let submit_ix = 264 | ore_pool_api::sdk::submit(operator.keypair.pubkey(), best_solution, attestation, bus); 265 | let sig = tx::submit::submit_instructions( 266 | &operator.keypair, 267 | &operator.rpc_client, 268 | &operator.jito_client, 269 | &[auth_ix, submit_ix], 270 | 550_000, 271 | 2_000, 272 | ) 273 | .await?; 274 | log::info!("{:?}", sig); 275 | 276 | // reset 277 | self.reset(operator).await?; 278 | Ok(()) 279 | } 280 | 281 | pub async fn distribute_rewards( 282 | &mut self, 283 | operator: &Operator, 284 | event: &PoolMiningEvent, 285 | ) -> Result<(), Error> { 286 | log::info!("{:?}", event); 287 | 288 | // Calculate pool 289 | let net_pool_rewards = event 290 | .mine_event 291 | .net_base_reward 292 | .checked_add(event.mine_event.net_miner_boost_reward) 293 | .unwrap(); 294 | 295 | // Compute operator rewards 296 | let operator_rewards = self.rewards_distribution_operator( 297 | operator.keypair.pubkey(), 298 | net_pool_rewards, 299 | operator.operator_commission, 300 | ); 301 | 302 | // Compute miner rewards 303 | let mut rewards_distribution = 304 | self.rewards_distribution(net_pool_rewards, operator_rewards.1); 305 | 306 | println!("rewards_distribution: {:?}", rewards_distribution); 307 | 308 | // Collect all rewards 309 | rewards_distribution.push(operator_rewards); 310 | 311 | // Write rewards to db 312 | let mut db_client = operator.db_client.get().await?; 313 | database::update_member_balances(&mut db_client, rewards_distribution.clone()).await?; 314 | 315 | // Get best member scores for this event 316 | let member_scores = if let Some(miner_contributions) = self 317 | .contributions 318 | .miners 319 | .get(&(event.mine_event.last_hash_at as u64)) 320 | { 321 | let mut member_scores = HashMap::new(); 322 | for contribution in miner_contributions.contributions.iter() { 323 | if contribution.score > *member_scores.get(&contribution.member).unwrap_or(&0) { 324 | member_scores.insert(contribution.member, contribution.score); 325 | } 326 | } 327 | member_scores 328 | } else { 329 | HashMap::new() 330 | }; 331 | 332 | // Insert record into recent events 333 | let mut event = event.clone(); 334 | event.member_scores = member_scores; 335 | event.member_rewards = HashMap::from_iter(rewards_distribution); 336 | self.recent_events 337 | .insert(event.mine_event.last_hash_at as u64, event); 338 | 339 | Ok(()) 340 | } 341 | 342 | fn rewards_distribution( 343 | &mut self, 344 | net_pool_rewards: u64, 345 | operator_rewards: u64, 346 | ) -> Vec<(Pubkey, u64)> { 347 | // Get attributed scores 348 | let contributions = &mut self.contributions; 349 | let (total_score, scores) = contributions.scores(); 350 | 351 | // Calculate total miner rewards 352 | let miner_rewards = net_pool_rewards.checked_sub(operator_rewards).unwrap(); 353 | log::info!("total miner rewards: {}", miner_rewards); 354 | 355 | // compute member split 356 | scores 357 | .iter() 358 | .map(|(member, member_score)| { 359 | let member_rewards = (miner_rewards as u128) 360 | .checked_mul(*member_score as u128) 361 | .unwrap() 362 | .checked_div(total_score as u128) 363 | .unwrap_or(0) as u64; 364 | (*member, member_rewards) 365 | }) 366 | .collect() 367 | } 368 | 369 | fn rewards_distribution_operator( 370 | &self, 371 | pool_authority: Pubkey, 372 | net_pool_rewards: u64, 373 | operator_commission: u64, 374 | ) -> (Pubkey, u64) { 375 | let operator_rewards = (net_pool_rewards as u128) 376 | .saturating_mul(operator_commission as u128) 377 | .saturating_div(100) as u64; 378 | log::info!("total operator rewards: {}", operator_rewards); 379 | (pool_authority, operator_rewards) 380 | } 381 | 382 | /// fetch the bus with the largest balance 383 | async fn find_bus(&self, operator: &Operator) -> Result { 384 | let rpc_client = &operator.rpc_client; 385 | let accounts = rpc_client.get_multiple_accounts(&BUS_ADDRESSES).await?; 386 | let mut top_bus_balance: u64 = 0; 387 | let bus_index = rand::thread_rng().gen_range(0..BUS_COUNT); 388 | let mut top_bus = BUS_ADDRESSES[bus_index]; 389 | for account in accounts.into_iter().flatten() { 390 | if let Ok(bus) = Bus::try_from_bytes(&account.data) { 391 | if bus.rewards.gt(&top_bus_balance) { 392 | top_bus_balance = bus.rewards; 393 | top_bus = BUS_ADDRESSES[bus.id as usize]; 394 | } 395 | } 396 | } 397 | Ok(top_bus) 398 | } 399 | 400 | fn attestation(&mut self) -> Result<[u8; 32], Error> { 401 | let mut hasher = Sha3_256::new(); 402 | let contributions = self.get_current_contributions()?; 403 | let num_contributions = contributions.contributions.len(); 404 | log::info!("num contributions: {}", num_contributions); 405 | for contribution in contributions.contributions.iter() { 406 | let hex_string: String = 407 | contribution 408 | .solution 409 | .d 410 | .iter() 411 | .fold(String::new(), |mut acc, byte| { 412 | acc.push_str(&format!("{:02x}", byte)); 413 | acc 414 | }); 415 | let line = format!( 416 | "{} {} {}\n", 417 | contribution.member, 418 | hex_string, 419 | u64::from_le_bytes(contribution.solution.n) 420 | ); 421 | hasher.update(&line); 422 | } 423 | let mut attestation: [u8; 32] = [0; 32]; 424 | attestation.copy_from_slice(&hasher.finalize()[..]); 425 | Ok(attestation) 426 | } 427 | 428 | fn get_current_contributions(&mut self) -> Result<&mut MinerContributions, Error> { 429 | let last_hash_at = self.current_challenge.lash_hash_at as u64; 430 | let contributions = &mut self.contributions; 431 | let contributions = contributions 432 | .miners 433 | .get_mut(&last_hash_at) 434 | .ok_or(Error::Internal( 435 | "missing contributions at current hash".to_string(), 436 | ))?; 437 | Ok(contributions) 438 | } 439 | 440 | async fn reset(&mut self, operator: &Operator) -> Result<(), Error> { 441 | log::info!("resetting"); 442 | 443 | // update challenge 444 | self.update_challenge(operator).await?; 445 | 446 | // allocate key for new contributions 447 | let last_hash_at = self.current_challenge.lash_hash_at as u64; 448 | let contributions = &mut self.contributions; 449 | log::info!("new contributions key: {:?}", last_hash_at); 450 | contributions.insert(last_hash_at); 451 | 452 | // reset accumulators 453 | let pool = operator.get_pool().await?; 454 | self.num_members = pool.last_total_members; 455 | Ok(()) 456 | } 457 | 458 | fn winner(&mut self) -> Result { 459 | let contributions = self.get_current_contributions()?; 460 | let winner = contributions.winner; 461 | winner.ok_or(Error::Internal("no solutions were submitted".to_string())) 462 | } 463 | 464 | async fn check_for_reset(&self, operator: &Operator) -> Result { 465 | let last_hash_at = self.current_challenge.lash_hash_at; 466 | let proof = operator.get_proof().await?; 467 | let needs_reset = proof.last_hash_at != last_hash_at; 468 | Ok(needs_reset) 469 | } 470 | 471 | async fn update_challenge(&mut self, operator: &Operator) -> Result<(), Error> { 472 | let max_retries = 10; 473 | let mut retries = 0; 474 | let last_hash_at = self.current_challenge.lash_hash_at; 475 | loop { 476 | let proof = operator.get_proof().await?; 477 | if proof.last_hash_at != last_hash_at { 478 | let cutoff_time = operator.get_cutoff(&proof).await?; 479 | let min_difficulty = operator.min_difficulty().await?; 480 | self.current_challenge.challenge = proof.challenge; 481 | self.current_challenge.lash_hash_at = proof.last_hash_at; 482 | self.current_challenge.min_difficulty = min_difficulty; 483 | self.current_challenge.cutoff_time = cutoff_time; 484 | return Ok(()); 485 | } else { 486 | retries += 1; 487 | if retries == max_retries { 488 | return Err(Error::Internal("failed to fetch new challenge".to_string())); 489 | } 490 | tokio::time::sleep(tokio::time::Duration::from_secs(1)).await; 491 | } 492 | } 493 | } 494 | } 495 | -------------------------------------------------------------------------------- /server/src/contributions.rs: -------------------------------------------------------------------------------- 1 | use std::collections::{HashMap, HashSet, VecDeque}; 2 | use std::hash::Hash; 3 | 4 | use drillx::Solution; 5 | use solana_sdk::pubkey::Pubkey; 6 | use solana_sdk::signature::Signature; 7 | 8 | pub struct Contributions { 9 | pub miners: HashMap, 10 | attribution_filter: AttributionFilter, 11 | } 12 | 13 | impl Contributions { 14 | pub fn new(attribution_filter_size: u8) -> Self { 15 | Self { 16 | miners: HashMap::new(), 17 | attribution_filter: AttributionFilter::new(attribution_filter_size), 18 | } 19 | } 20 | 21 | pub fn insert(&mut self, ts: LastHashAt) { 22 | let contributions = MinerContributions { 23 | contributions: HashSet::new(), 24 | winner: None, 25 | total_score: 0, 26 | }; 27 | if let Some(_) = self.miners.insert(ts, contributions) { 28 | log::error!("contributions at last-hash-at already exist: {}", ts); 29 | } 30 | self.attribution_filter.push(ts); 31 | } 32 | 33 | pub fn scores(&mut self) -> (TotalScore, Vec<(Miner, u64)>) { 34 | // filter for valid timestamps 35 | self.filter(); 36 | 37 | // sum contribution scores for each member 38 | let mut total_score: u64 = 0; 39 | let mut merge: HashMap = HashMap::new(); 40 | let duplicates = self 41 | .miners 42 | .values() 43 | .flat_map(|mc| mc.contributions.iter()) 44 | .map(|c| (c.member, c.score)); 45 | for (k, v) in duplicates { 46 | *merge.entry(k).or_insert(0) += v; 47 | total_score += v; 48 | } 49 | (total_score, merge.into_iter().collect()) 50 | } 51 | 52 | fn filter(&mut self) { 53 | let validation = &self.attribution_filter.time_stamps; 54 | self.miners.retain(|k, _v| validation.contains(k)); 55 | } 56 | } 57 | 58 | /// miner authority 59 | pub type Miner = Pubkey; 60 | 61 | /// miner lookup table 62 | /// challenge --> contribution 63 | pub type LastHashAt = u64; 64 | pub struct MinerContributions { 65 | pub contributions: HashSet, 66 | pub winner: Option, 67 | pub total_score: u64, 68 | } 69 | 70 | /// total score per challenge 71 | pub type TotalScore = u64; 72 | 73 | /// timestamps of last n challenges 74 | pub struct AttributionFilter { 75 | /// total num elements in vec 76 | pub len: u8, 77 | /// max size of vec 78 | pub size: u8, 79 | /// elements 80 | pub time_stamps: VecDeque, 81 | } 82 | 83 | impl AttributionFilter { 84 | pub fn new(size: u8) -> Self { 85 | Self { 86 | len: 0, 87 | size, 88 | time_stamps: VecDeque::with_capacity(size as usize), 89 | } 90 | } 91 | pub fn push(&mut self, ts: LastHashAt) { 92 | if self.len.lt(&self.size) { 93 | self.len += 1; 94 | self.time_stamps.push_back(ts); 95 | } else { 96 | self.time_stamps.pop_front(); 97 | self.time_stamps.push_back(ts); 98 | } 99 | } 100 | } 101 | 102 | /// Best hash to be submitted for the current challenge. 103 | #[derive(Clone, Copy, Debug)] 104 | pub struct Winner { 105 | /// The winning solution. 106 | pub solution: Solution, 107 | 108 | /// The current largest difficulty. 109 | pub difficulty: u32, 110 | } 111 | 112 | /// A recorded contribution from a particular member of the pool. 113 | #[derive(Clone, Copy, Debug)] 114 | pub struct Contribution { 115 | /// The member who submitted this solution. 116 | pub member: Pubkey, 117 | 118 | /// The difficulty score of the solution. 119 | pub score: u64, 120 | 121 | /// The drillx solution submitted representing the member's best hash. 122 | pub solution: Solution, 123 | } 124 | 125 | impl PartialEq for Contribution { 126 | fn eq(&self, other: &Self) -> bool { 127 | self.member == other.member 128 | } 129 | } 130 | 131 | impl Eq for Contribution {} 132 | 133 | impl Hash for Contribution { 134 | fn hash(&self, state: &mut H) { 135 | self.member.hash(state); 136 | } 137 | } 138 | 139 | /// Tracks recent mining events and rewards for each submission 140 | pub struct RecentEvents { 141 | /// Maps last_hash_at timestamp to mining event data 142 | events: HashMap, 143 | /// Maximum number of events to keep in memory 144 | max_events: usize, 145 | } 146 | 147 | #[derive(Clone, Debug)] 148 | pub struct PoolMiningEvent { 149 | pub signature: Signature, 150 | pub block: u64, 151 | pub timestamp: u64, 152 | pub mine_event: ore_api::event::MineEvent, 153 | pub member_rewards: HashMap, 154 | pub member_scores: HashMap, 155 | } 156 | 157 | impl RecentEvents { 158 | pub fn new(max_events: usize) -> Self { 159 | Self { 160 | events: HashMap::with_capacity(max_events), 161 | max_events, 162 | } 163 | } 164 | 165 | pub fn keys(&self) -> Vec { 166 | self.events.keys().cloned().collect() 167 | } 168 | 169 | pub fn insert(&mut self, last_hash_at: LastHashAt, event: PoolMiningEvent) { 170 | if self.events.len() >= self.max_events { 171 | // Remove oldest event if at capacity 172 | if let Some(oldest) = self.events.keys().min().copied() { 173 | self.events.remove(&oldest); 174 | } 175 | } 176 | self.events.insert(last_hash_at, event); 177 | } 178 | 179 | pub fn get(&self, last_hash_at: LastHashAt) -> Option<&PoolMiningEvent> { 180 | self.events.get(&last_hash_at) 181 | } 182 | } -------------------------------------------------------------------------------- /server/src/database.rs: -------------------------------------------------------------------------------- 1 | use std::{env, str::FromStr, sync::Arc}; 2 | 3 | use crate::{error::Error, operator::Operator, tx}; 4 | use deadpool_postgres::{GenericClient, Object, Pool}; 5 | use futures::TryStreamExt; 6 | use futures_util::pin_mut; 7 | use ore_pool_api::state::member_pda; 8 | use solana_sdk::{instruction::Instruction, pubkey::Pubkey, signer::Signer}; 9 | use tokio_postgres::NoTls; 10 | 11 | pub fn create_pool() -> Pool { 12 | let mut cfg = deadpool_postgres::Config::new(); 13 | cfg.url = Some(env::var("DB_URL").expect("DB_URL must be set").to_string()); 14 | cfg.create_pool(None, NoTls).unwrap() 15 | } 16 | 17 | // when writing new balances 18 | // also sets the is-synced field to false 19 | // so that in the attribution loop we know which accounts 20 | // have been incremented in the db but not yet on-chain 21 | pub async fn update_member_balances( 22 | conn: &mut Object, 23 | increments: Vec<(Pubkey, u64)>, 24 | ) -> Result<(), Error> { 25 | let transaction = conn.transaction().await?; 26 | for (address, increment) in increments.iter() { 27 | transaction 28 | .execute( 29 | "UPDATE members SET total_balance = total_balance + $1, is_synced = false WHERE authority = $2", 30 | &[&(*increment as i64), &address.to_string()], 31 | ) 32 | .await?; 33 | } 34 | transaction.commit().await?; 35 | Ok(()) 36 | } 37 | 38 | // streams all records from db where is-synced is false 39 | // updates on-chain balances in batches and marks records in db as synced, 40 | // the on-chain attribution instruction is idempotent 41 | // so any failures here are recoverable 42 | const NUM_ATTRIBUTIONS_PER_TX: usize = 10; 43 | pub async fn stream_members_attribution( 44 | conn: Arc, 45 | operator: Arc, 46 | ) -> Result<(), Error> { 47 | // fetch count(*) to determine min buffer size 48 | let count_query = "SELECT COUNT(*) FROM members WHERE is_synced = false"; 49 | let row = conn.query_one(count_query, &[]).await?; 50 | let record_count: i64 = row.try_get(0)?; 51 | 52 | // build stream of memebrs to be attributed 53 | let stmt = "SELECT address, authority, total_balance FROM members WHERE is_synced = false"; 54 | let params: Vec = vec![]; 55 | let stream = conn.query_raw(stmt, params).await?; 56 | pin_mut!(stream); 57 | 58 | // buffer stream for packing attributions transaction 59 | let signer = operator.keypair.pubkey(); 60 | let buffer_size = NUM_ATTRIBUTIONS_PER_TX.min(record_count as usize); 61 | let mut ix_buffer: Vec = Vec::with_capacity(buffer_size); 62 | let mut address_buffer: Vec = Vec::with_capacity(buffer_size); 63 | let mut handles: Vec> = vec![]; 64 | while let Some(row) = stream.try_next().await? { 65 | // parse row 66 | let address: String = row.try_get(0)?; 67 | let member_authority: String = row.try_get(1)?; 68 | let member_authority = Pubkey::from_str(member_authority.as_str())?; 69 | let total_balance: i64 = row.try_get(2)?; 70 | 71 | // build instruction 72 | let ix = ore_pool_api::sdk::attribute(signer, member_authority, total_balance as u64); 73 | ix_buffer.push(ix); 74 | address_buffer.push(address); 75 | 76 | // if buffer is full 77 | if ix_buffer.len().eq(&buffer_size) { 78 | let conn = conn.clone(); 79 | let operator = operator.clone(); 80 | let handle = tokio::spawn({ 81 | let ix_buffer = ix_buffer.clone(); 82 | let address_buffer = address_buffer.clone(); 83 | async move { 84 | match tx::submit::submit_and_confirm_instructions( 85 | &operator.keypair, 86 | &operator.rpc_client, 87 | &operator.jito_client, 88 | ix_buffer.as_slice(), 89 | 60_000, 90 | 2_000, 91 | ) 92 | .await 93 | { 94 | Ok(sig) => { 95 | log::info!("attribution sig: {:?}", sig); 96 | // mark as synced 97 | if let Err(err) = 98 | write_synced_members(conn.as_ref(), address_buffer.as_slice()).await 99 | { 100 | log::error!("{:?}", err); 101 | } 102 | } 103 | Err(err) => { 104 | log::error!("attribution failure: {:?}", err); 105 | } 106 | } 107 | } 108 | }); 109 | handles.push(handle); 110 | 111 | // clear buffers 112 | address_buffer.clear(); 113 | ix_buffer.clear(); 114 | } 115 | } 116 | 117 | // join handles 118 | let _ = futures::future::join_all(handles).await; 119 | Ok(()) 120 | } 121 | 122 | pub async fn write_synced_members(conn: &Object, address_buffer: &[String]) -> Result<(), Error> { 123 | let query = "UPDATE members SET is_synced = true WHERE address = ANY($1)"; 124 | conn.execute(query, &[&address_buffer]).await?; 125 | Ok(()) 126 | } 127 | 128 | pub async fn write_new_member( 129 | conn: &Object, 130 | member: &ore_pool_api::state::Member, 131 | approved: bool, 132 | ) -> Result { 133 | let member = ore_pool_types::Member { 134 | address: member_pda(member.authority, member.pool).0.to_string(), 135 | id: (member.id as i64), 136 | authority: member.authority.to_string(), 137 | pool_address: member.pool.to_string(), 138 | total_balance: 0, 139 | is_approved: approved, 140 | is_kyc: false, 141 | is_synced: true, 142 | }; 143 | conn.execute( 144 | "INSERT INTO members 145 | (address, id, authority, pool_address, total_balance, is_approved, is_kyc, is_synced) 146 | VALUES ($1, $2, $3, $4, $5, $6, $7, $8)", 147 | &[ 148 | &member.address, 149 | &member.id, 150 | &member.authority, 151 | &member.pool_address, 152 | &member.total_balance, 153 | &member.is_approved, 154 | &member.is_kyc, 155 | &member.is_synced, 156 | ], 157 | ) 158 | .await?; 159 | Ok(member) 160 | } 161 | 162 | pub async fn read_member(conn: &Object, address: &String) -> Result { 163 | let row = conn 164 | .query_one( 165 | &format!( 166 | "SELECT address, id, authority, pool_address, total_balance, is_approved, is_kyc, is_synced 167 | FROM members 168 | WHERE address = '{}'", 169 | address 170 | ), 171 | &[], 172 | ) 173 | .await?; 174 | Ok(ore_pool_types::Member { 175 | address: row.try_get(0)?, 176 | id: row.try_get(1)?, 177 | authority: row.try_get(2)?, 178 | pool_address: row.try_get(3)?, 179 | total_balance: row.try_get(4)?, 180 | is_approved: row.try_get(5)?, 181 | is_kyc: row.try_get(6)?, 182 | is_synced: row.try_get(7)?, 183 | }) 184 | } 185 | -------------------------------------------------------------------------------- /server/src/error.rs: -------------------------------------------------------------------------------- 1 | use actix_web::{http::header::ToStrError, HttpResponse}; 2 | 3 | #[derive(thiserror::Error, Debug)] 4 | pub enum Error { 5 | #[error("bincode")] 6 | Bincode(#[from] bincode::Error), 7 | #[error("base64 decode")] 8 | Base64Decode(#[from] base64::DecodeError), 9 | #[error("try from slice")] 10 | TryFromSlice(#[from] std::array::TryFromSliceError), 11 | #[error("rewards channel send")] 12 | RewardsChannelSend( 13 | #[from] 14 | tokio::sync::mpsc::error::SendError, 15 | ), 16 | #[error("tokio postgres")] 17 | TokioPostgres(#[from] tokio_postgres::Error), 18 | #[error("deadpool postgress")] 19 | DeadpoolPostgres(#[from] deadpool_postgres::PoolError), 20 | #[error("http header to string")] 21 | HttpHeader(#[from] ToStrError), 22 | #[error("reqwest")] 23 | Reqwest(#[from] reqwest::Error), 24 | #[error("serde json")] 25 | SerdeJson(#[from] serde_json::Error), 26 | #[error("std io")] 27 | StdIO(#[from] std::io::Error), 28 | #[error("std env")] 29 | StdEnv(#[from] std::env::VarError), 30 | #[error("std parse int")] 31 | StdParseInt(#[from] std::num::ParseIntError), 32 | #[error("solana client")] 33 | SolanaClient(#[from] solana_client::client_error::ClientError), 34 | #[error("solana program")] 35 | SolanaProgram(#[from] solana_sdk::program_error::ProgramError), 36 | #[error("solana pubkey")] 37 | SolanaPubkey(#[from] solana_sdk::pubkey::ParsePubkeyError), 38 | #[error("member doesn't exist yet")] 39 | MemberDoesNotExist, 40 | #[error("staker doesn't exist yet")] 41 | StakerDoesNotExist, 42 | #[error("share account received")] 43 | ShareAccountReceived, 44 | #[error("proof account received")] 45 | ProofAccountReceived, 46 | #[error("{0}")] 47 | Internal(String), 48 | } 49 | 50 | impl From for HttpResponse { 51 | fn from(value: Error) -> Self { 52 | match value { 53 | Error::MemberDoesNotExist | Error::StakerDoesNotExist => { 54 | HttpResponse::NotFound().finish() 55 | } 56 | Error::ShareAccountReceived => HttpResponse::Ok().finish(), 57 | _ => HttpResponse::InternalServerError().finish(), 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /server/src/handlers.rs: -------------------------------------------------------------------------------- 1 | use std::str::FromStr; 2 | 3 | use actix_web::{web, HttpResponse, Responder}; 4 | use ore_pool_types::{ 5 | BalanceUpdate, ContributePayloadV2, GetChallengePayload, GetEventPayload, GetMemberPayload, 6 | MemberChallenge, PoolAddress, PoolMemberMiningEvent, RegisterPayload, UpdateBalancePayload, 7 | }; 8 | use solana_sdk::{pubkey::Pubkey, signer::Signer}; 9 | 10 | use crate::{aggregator::Aggregator, database, error::Error, operator::Operator, tx, Contribution}; 11 | 12 | const NUM_CLIENT_DEVICES: u8 = 5; 13 | 14 | pub async fn register( 15 | operator: web::Data, 16 | payload: web::Json, 17 | ) -> impl Responder { 18 | let operator = operator.as_ref(); 19 | let res = register_new_member(operator, payload.into_inner()).await; 20 | match res { 21 | Ok(db_member) => HttpResponse::Ok().json(&db_member), 22 | Err(err) => { 23 | log::error!("{:?}", err); 24 | let http_response: HttpResponse = err.into(); 25 | http_response 26 | } 27 | } 28 | } 29 | 30 | pub async fn address(operator: web::Data) -> impl Responder { 31 | let operator = operator.as_ref(); 32 | let (pool_pda, bump) = ore_pool_api::state::pool_pda(operator.keypair.pubkey()); 33 | HttpResponse::Ok().json(&PoolAddress { 34 | address: pool_pda, 35 | bump, 36 | }) 37 | } 38 | 39 | pub async fn commit_balance( 40 | operator: web::Data, 41 | payload: web::Json, 42 | ) -> impl Responder { 43 | match update_balance_onchain(operator.as_ref(), payload.into_inner()).await { 44 | Ok(balance_update) => HttpResponse::Ok().json(balance_update), 45 | Err(err) => { 46 | log::error!("{:?}", err); 47 | HttpResponse::InternalServerError().body(err.to_string()) 48 | } 49 | } 50 | } 51 | 52 | pub async fn member( 53 | operator: web::Data, 54 | path: web::Path, 55 | ) -> impl Responder { 56 | match operator 57 | .get_member_db(path.into_inner().authority.as_str()) 58 | .await 59 | { 60 | Ok(member) => HttpResponse::Ok().json(&member), 61 | Err(err) => { 62 | log::error!("{:?}", err); 63 | HttpResponse::NotFound().finish() 64 | } 65 | } 66 | } 67 | 68 | pub async fn challenge( 69 | aggregator: web::Data>, 70 | clock_tx: web::Data>, 71 | _path: web::Path, 72 | ) -> impl Responder { 73 | // Read from clock 74 | let mut clock_rx = clock_tx.subscribe(); 75 | let unix_timestamp = match clock_rx.recv().await { 76 | Ok(ts) => ts, 77 | Err(err) => { 78 | log::error!("{:?}", err); 79 | return HttpResponse::InternalServerError().body(err.to_string()); 80 | } 81 | }; 82 | // Acquire write on aggregator for challenge 83 | let (challenge, last_num_members) = { 84 | let aggregator = aggregator.read().await; 85 | (aggregator.current_challenge, aggregator.num_members) 86 | }; 87 | 88 | // Build member challenge 89 | #[allow(deprecated)] 90 | let member_challenge = MemberChallenge { 91 | challenge, 92 | num_total_members: last_num_members, 93 | device_id: 0, 94 | num_devices: NUM_CLIENT_DEVICES, 95 | unix_timestamp: unix_timestamp, 96 | }; 97 | HttpResponse::Ok().json(&member_challenge) 98 | } 99 | 100 | /// Accepts solutions from pool members. If their solutions are valid, it 101 | /// aggregates the contributions into a list for publishing and submission. 102 | pub async fn contribute( 103 | operator: web::Data, 104 | aggregator: web::Data>, 105 | tx: web::Data>, 106 | payload: web::Json, 107 | ) -> impl Responder { 108 | // acquire read on aggregator for challenge 109 | let aggregator = aggregator.read().await; 110 | let challenge = aggregator.current_challenge; 111 | let num_members = aggregator.num_members; 112 | drop(aggregator); 113 | 114 | // decode solution difficulty 115 | let solution = &payload.solution; 116 | let difficulty = solution.to_hash().difficulty(); 117 | let score = 2u64.pow(difficulty); 118 | 119 | // error if solution below min difficulty 120 | if difficulty < (challenge.min_difficulty as u32) { 121 | log::error!( 122 | "solution below min difficulity: {:?} received: {:?} required: {:?}", 123 | payload.authority, 124 | difficulty, 125 | challenge.min_difficulty 126 | ); 127 | return HttpResponse::BadRequest().finish(); 128 | } 129 | 130 | // validate nonce 131 | let member_authority = &payload.authority; 132 | let nonce = solution.n; 133 | let nonce = u64::from_le_bytes(nonce); 134 | if let Err(err) = validate_nonce(operator.as_ref(), member_authority, nonce, num_members).await 135 | { 136 | log::error!("{:?}", err); 137 | return HttpResponse::Unauthorized().finish(); 138 | } 139 | 140 | // update the aggegator 141 | if let Err(err) = tx.send(Contribution { 142 | member: payload.authority, 143 | score, 144 | solution: payload.solution, 145 | }) { 146 | log::error!("{:?}", err); 147 | } 148 | HttpResponse::Ok().finish() 149 | } 150 | 151 | pub async fn latest_event( 152 | aggregator: web::Data>, 153 | path: web::Path, 154 | ) -> impl Responder { 155 | // Parse miner pubkey 156 | let miner = match Pubkey::from_str(path.authority.as_str()) { 157 | Ok(authority) => authority, 158 | Err(err) => { 159 | return HttpResponse::BadRequest().body(err.to_string()); 160 | } 161 | }; 162 | 163 | // Read latest event 164 | let aggregator = aggregator.read().await; 165 | if let Some(latest_key) = aggregator.recent_events.keys().iter().max().copied() { 166 | if let Some(pool_event) = aggregator.recent_events.get(latest_key) { 167 | let resp = PoolMemberMiningEvent { 168 | signature: pool_event.signature, 169 | block: pool_event.block, 170 | timestamp: pool_event.timestamp, 171 | balance: pool_event.mine_event.balance, 172 | difficulty: pool_event.mine_event.difficulty, 173 | last_hash_at: pool_event.mine_event.last_hash_at, 174 | timing: pool_event.mine_event.timing, 175 | net_reward: pool_event.mine_event.net_reward, 176 | net_base_reward: pool_event.mine_event.net_base_reward, 177 | net_miner_boost_reward: pool_event.mine_event.net_miner_boost_reward, 178 | net_staker_boost_reward: pool_event.mine_event.net_staker_boost_reward, 179 | member_difficulty: { 180 | let score = pool_event.member_scores.get(&miner).unwrap_or(&0); 181 | if *score > 0 { 182 | score.ilog2() as u64 183 | } else { 184 | 0 185 | } 186 | }, 187 | member_reward: *pool_event.member_rewards.get(&miner).unwrap_or(&0), 188 | }; 189 | return HttpResponse::Ok().json(resp); 190 | } 191 | } 192 | 193 | // Otherwise return 404 194 | HttpResponse::NotFound().finish() 195 | } 196 | 197 | async fn update_balance_onchain( 198 | operator: &Operator, 199 | payload: UpdateBalancePayload, 200 | ) -> Result { 201 | let keypair = &operator.keypair; 202 | let member_authority = payload.authority; 203 | let hash = payload.hash; 204 | 205 | // fetch member balance 206 | let member = operator 207 | .get_member_db(member_authority.to_string().as_str()) 208 | .await?; 209 | 210 | // assert that the fee payer is someone else 211 | let tx = payload.transaction; 212 | let fee_payer = *tx.message.signer_keys().first().ok_or(Error::Internal( 213 | "missing fee payer in update balance payload".to_string(), 214 | ))?; 215 | if fee_payer.eq(&keypair.pubkey()) { 216 | return Err(Error::Internal( 217 | "fee payer must be client for update balance".to_string(), 218 | )); 219 | } 220 | 221 | // validate transaction 222 | let (pool_address, _) = ore_pool_api::state::pool_pda(keypair.pubkey()); 223 | tx::validate::validate_attribution(&tx, member_authority, pool_address, member.total_balance)?; 224 | 225 | // sign transaction and submit 226 | // TODO: submit jito 227 | let mut tx = tx; 228 | let rpc_client = &operator.rpc_client; 229 | tx.partial_sign(&[keypair], hash); 230 | let sig = tx::submit::submit_and_confirm_transaction(rpc_client, &tx).await?; 231 | log::info!("on demand attribution sig: {:?}", sig); 232 | 233 | // set member as synced in db 234 | let db_client = &operator.db_client; 235 | let db_client = db_client.get().await?; 236 | let (member_address, _) = ore_pool_api::state::member_pda(member_authority, pool_address); 237 | database::write_synced_members(&db_client, &[member_address.to_string()]).await?; 238 | Ok(BalanceUpdate { 239 | balance: member.total_balance as u64, 240 | signature: sig, 241 | }) 242 | } 243 | 244 | async fn register_new_member( 245 | operator: &Operator, 246 | payload: RegisterPayload, 247 | ) -> Result { 248 | let keypair = &operator.keypair; 249 | let member_authority = payload.authority; 250 | let (pool_pda, _) = ore_pool_api::state::pool_pda(keypair.pubkey()); 251 | 252 | // fetch db record 253 | let db_client = operator.db_client.get().await?; 254 | let (member_pda, _) = ore_pool_api::state::member_pda(member_authority, pool_pda); 255 | let db_member = database::read_member(&db_client, &member_pda.to_string()).await; 256 | 257 | // idempotent get or create 258 | match db_member { 259 | Ok(db_member) => { 260 | // member already exists in db 261 | Ok(db_member) 262 | } 263 | Err(_) => { 264 | // member not in db 265 | // check if on-chain account already exists 266 | let member = operator.get_member_onchain(&member_authority).await; 267 | match member { 268 | Ok(member) => { 269 | // write member to db 270 | let db_member = database::write_new_member(&db_client, &member, false).await?; 271 | Ok(db_member) 272 | } 273 | Err(err) => { 274 | // member doesn't exist yet on-chain 275 | // land tx to create new member account 276 | log::error!("{:?}", err); 277 | // return error to http client 278 | // bc they should create the member account before hitting this path 279 | Err(Error::MemberDoesNotExist) 280 | } 281 | } 282 | } 283 | } 284 | } 285 | 286 | // TODO: consider fitting lookup table from member authority to id, in memory 287 | async fn validate_nonce( 288 | operator: &Operator, 289 | member_authority: &Pubkey, 290 | nonce: u64, 291 | num_members: u64, 292 | ) -> Result<(), Error> { 293 | if num_members.eq(&0) { 294 | return Ok(()); 295 | } 296 | let member = operator 297 | .get_member_db(member_authority.to_string().as_str()) 298 | .await?; 299 | let nonce_index = member.id as u64; 300 | let u64_unit = u64::MAX.saturating_div(num_members); 301 | let left_bound = u64_unit.saturating_mul(nonce_index); 302 | let right_bound = u64_unit.saturating_mul(nonce_index + 1); 303 | let ge_left = nonce >= left_bound; 304 | let le_right = nonce <= right_bound; 305 | if ge_left && le_right { 306 | Ok(()) 307 | } else { 308 | Err(Error::Internal("invalid nonce from client".to_string())) 309 | } 310 | } 311 | -------------------------------------------------------------------------------- /server/src/main.rs: -------------------------------------------------------------------------------- 1 | mod aggregator; 2 | mod contributions; 3 | mod database; 4 | mod error; 5 | mod handlers; 6 | mod operator; 7 | mod tx; 8 | mod utils; 9 | mod webhook; 10 | 11 | use core::panic; 12 | 13 | use actix_web::{get, middleware, web, App, HttpResponse, HttpServer, Responder}; 14 | use aggregator::Aggregator; 15 | use contributions::{Contribution, PoolMiningEvent}; 16 | use operator::Operator; 17 | use utils::create_cors; 18 | 19 | // TODO: publish attestation to s3 20 | // write attestation url to db with last-hash-at as foreign key 21 | #[actix_web::main] 22 | async fn main() -> Result<(), error::Error> { 23 | env_logger::init(); 24 | // env vars 25 | let attribution_epoch = attribution_epoch()?; 26 | 27 | // events channel 28 | let (events_tx, mut events_rx) = tokio::sync::mpsc::channel::(1); 29 | let events_tx = web::Data::new(events_tx); 30 | 31 | // contributions channel 32 | let (contributions_tx, mut contributions_rx) = 33 | tokio::sync::mpsc::unbounded_channel::(); 34 | let contributions_tx = web::Data::new(contributions_tx); 35 | 36 | // clock channel 37 | let (clock_tx, _) = tokio::sync::broadcast::channel::(1); 38 | let clock_tx = web::Data::new(clock_tx); 39 | 40 | // operator and aggregator mutex 41 | let operator = web::Data::new(Operator::new()?); 42 | let aggregator = web::Data::new(tokio::sync::RwLock::new(Aggregator::new(&operator).await?)); 43 | 44 | // aggregate contributions 45 | tokio::task::spawn({ 46 | let operator = operator.clone(); 47 | let aggregator = aggregator.clone(); 48 | async move { 49 | if let Err(err) = aggregator::process_contributions( 50 | aggregator.as_ref(), 51 | operator.as_ref(), 52 | &mut contributions_rx, 53 | ) 54 | .await 55 | { 56 | log::error!("{:?}", err); 57 | } 58 | } 59 | }); 60 | 61 | // distribute rewards 62 | tokio::task::spawn({ 63 | let operator = operator.clone(); 64 | let aggregator = aggregator.clone(); 65 | async move { 66 | loop { 67 | match events_rx.recv().await { 68 | Some(rewards) => { 69 | let mut aggregator = aggregator.write().await; 70 | if let Err(err) = aggregator 71 | .distribute_rewards(operator.as_ref(), &rewards) 72 | .await 73 | { 74 | log::error!("{:?}", err); 75 | } 76 | } 77 | None => { 78 | panic!("rewards channel closed") 79 | } 80 | }; 81 | } 82 | } 83 | }); 84 | 85 | // kick off attribution loop 86 | tokio::task::spawn({ 87 | let operator = operator.clone(); 88 | async move { 89 | loop { 90 | // submit attributions 91 | let operator = operator.clone().into_inner(); 92 | if let Err(err) = operator.attribute_members().await { 93 | panic!("{:?}", err) 94 | } 95 | // sleep until next epoch 96 | tokio::time::sleep(tokio::time::Duration::from_secs(60 * attribution_epoch)).await; 97 | } 98 | } 99 | }); 100 | 101 | // clock 102 | tokio::task::spawn({ 103 | let operator = operator.clone(); 104 | let clock_tx = clock_tx.clone(); 105 | async move { 106 | // every 10 seconds fetch the rpc clock 107 | loop { 108 | let mut ticks = 0; 109 | let mut unix_timestamp = match operator.get_clock().await { 110 | Ok(clock) => clock.unix_timestamp, 111 | Err(err) => { 112 | log::error!("{:?}", err); 113 | continue; 114 | } 115 | }; 116 | // every 1 seconds simulate a tick 117 | loop { 118 | // reset every 10 ticks 119 | ticks += 1; 120 | if ticks.eq(&10) { 121 | break; 122 | } 123 | // send tick 124 | let _ = clock_tx.send(unix_timestamp); 125 | // simulate tick of rpc clock 126 | unix_timestamp += 1; 127 | tokio::time::sleep(tokio::time::Duration::from_secs(1)).await; 128 | } 129 | } 130 | } 131 | }); 132 | 133 | // launch server 134 | HttpServer::new(move || { 135 | log::info!("starting pool server"); 136 | App::new() 137 | .wrap(middleware::Logger::default()) 138 | .wrap(create_cors()) 139 | .app_data(contributions_tx.clone()) 140 | .app_data(clock_tx.clone()) 141 | .app_data(operator.clone()) 142 | .app_data(aggregator.clone()) 143 | .app_data(events_tx.clone()) 144 | .service(web::resource("/address").route(web::get().to(handlers::address))) 145 | .service(web::resource("/challenge").route(web::get().to(handlers::challenge))) 146 | .service( 147 | web::resource("/challenge/{authority}").route(web::get().to(handlers::challenge)), 148 | ) 149 | .service(web::resource("/contribute").route(web::post().to(handlers::contribute))) 150 | .service(web::resource("/commit").route(web::post().to(handlers::commit_balance))) 151 | .service( 152 | web::resource("/event/latest/{authority}") 153 | .route(web::get().to(handlers::latest_event)), 154 | ) 155 | .service(web::resource("/member/{authority}").route(web::get().to(handlers::member))) 156 | .service(web::resource("/register").route(web::post().to(handlers::register))) 157 | .service(web::resource("/webhook/rewards").route(web::post().to(webhook::mine_event))) 158 | .service(health) 159 | }) 160 | .bind("0.0.0.0:3000")? 161 | .run() 162 | .await 163 | .map_err(From::from) 164 | } 165 | 166 | // denominated in minutes 167 | fn attribution_epoch() -> Result { 168 | let string = std::env::var("ATTR_EPOCH")?; 169 | let epoch: u64 = string.parse()?; 170 | Ok(epoch) 171 | } 172 | 173 | #[get("/health")] 174 | async fn health() -> impl Responder { 175 | HttpResponse::Ok().body("ok") 176 | } 177 | -------------------------------------------------------------------------------- /server/src/operator.rs: -------------------------------------------------------------------------------- 1 | use std::{str::FromStr, sync::Arc}; 2 | 3 | use ore_api::state::{Config, Proof}; 4 | use ore_pool_api::state::{Member, Pool}; 5 | use solana_client::nonblocking::rpc_client::RpcClient; 6 | use solana_sdk::{ 7 | clock::Clock, 8 | commitment_config::CommitmentConfig, 9 | pubkey::Pubkey, 10 | signature::Keypair, 11 | signer::{EncodableKey, Signer}, 12 | sysvar, 13 | }; 14 | use steel::AccountDeserialize; 15 | 16 | use crate::{database, error::Error}; 17 | 18 | pub const BUFFER_OPERATOR: u64 = 5; 19 | const MIN_DIFFICULTY: Option = Some(7); 20 | 21 | pub struct Operator { 22 | /// The pool authority keypair. 23 | pub keypair: Keypair, 24 | 25 | /// Solana RPC client. 26 | pub rpc_client: RpcClient, 27 | 28 | /// JITO RPC client. 29 | pub jito_client: RpcClient, 30 | 31 | /// Postgres connection pool. 32 | pub db_client: deadpool_postgres::Pool, 33 | 34 | /// The operator commission in % percentage. 35 | /// Applied to the miner and staker rewards. 36 | pub operator_commission: u64, 37 | } 38 | 39 | impl Operator { 40 | pub fn new() -> Result { 41 | let keypair = Self::keypair()?; 42 | let rpc_client = Self::rpc_client()?; 43 | let jito_client = Self::jito_client(); 44 | let db_client = database::create_pool(); 45 | let operator_commission = Self::operator_commission()?; 46 | log::info!("operator commision: {}", operator_commission); 47 | Ok(Operator { 48 | keypair, 49 | rpc_client, 50 | jito_client, 51 | db_client, 52 | operator_commission, 53 | }) 54 | } 55 | 56 | pub async fn get_pool(&self) -> Result { 57 | let authority = self.keypair.pubkey(); 58 | let rpc_client = &self.rpc_client; 59 | let (pool_pda, _) = ore_pool_api::state::pool_pda(authority); 60 | let data = rpc_client.get_account_data(&pool_pda).await?; 61 | let pool = Pool::try_from_bytes(data.as_slice())?; 62 | Ok(*pool) 63 | } 64 | 65 | pub async fn get_member_onchain(&self, member_authority: &Pubkey) -> Result { 66 | let authority = self.keypair.pubkey(); 67 | let rpc_client = &self.rpc_client; 68 | let (pool_pda, _) = ore_pool_api::state::pool_pda(authority); 69 | let (member_pda, _) = ore_pool_api::state::member_pda(*member_authority, pool_pda); 70 | let data = rpc_client.get_account_data(&member_pda).await?; 71 | let member = Member::try_from_bytes(data.as_slice())?; 72 | Ok(*member) 73 | } 74 | 75 | pub async fn get_member_db( 76 | &self, 77 | member_authority: &str, 78 | ) -> Result { 79 | let db_client = self.db_client.get().await?; 80 | let member_authority = Pubkey::from_str(member_authority)?; 81 | let pool_authority = self.keypair.pubkey(); 82 | let (pool_pda, _) = ore_pool_api::state::pool_pda(pool_authority); 83 | let (member_pda, _) = ore_pool_api::state::member_pda(member_authority, pool_pda); 84 | database::read_member(&db_client, &member_pda.to_string()).await 85 | } 86 | 87 | pub async fn get_proof(&self) -> Result { 88 | let authority = self.keypair.pubkey(); 89 | let rpc_client = &self.rpc_client; 90 | let (pool_pda, _) = ore_pool_api::state::pool_pda(authority); 91 | let (proof_pda, _) = ore_pool_api::state::pool_proof_pda(pool_pda); 92 | let data = rpc_client.get_account_data(&proof_pda).await?; 93 | let proof = Proof::try_from_bytes(data.as_slice())?; 94 | Ok(*proof) 95 | } 96 | 97 | pub async fn get_cutoff(&self, proof: &Proof) -> Result { 98 | let clock = self.get_clock().await?; 99 | Ok(proof 100 | .last_hash_at 101 | .saturating_add(60) 102 | .saturating_sub(BUFFER_OPERATOR as i64) 103 | .saturating_sub(clock.unix_timestamp) 104 | .max(0) as u64) 105 | } 106 | 107 | pub async fn min_difficulty(&self) -> Result { 108 | let config = self.get_config().await?; 109 | let program_min = config.min_difficulty; 110 | match MIN_DIFFICULTY { 111 | Some(operator_min) => { 112 | let max = program_min.max(operator_min); 113 | Ok(max) 114 | } 115 | None => Ok(program_min), 116 | } 117 | } 118 | 119 | pub async fn attribute_members(self: Arc) -> Result<(), Error> { 120 | let db_client = self.db_client.get().await?; 121 | let db_client = Arc::new(db_client); 122 | database::stream_members_attribution(db_client, self).await?; 123 | Ok(()) 124 | } 125 | 126 | async fn get_config(&self) -> Result { 127 | let config_pda = ore_api::consts::CONFIG_ADDRESS; 128 | let rpc_client = &self.rpc_client; 129 | let data = rpc_client.get_account_data(&config_pda).await?; 130 | let config = Config::try_from_bytes(data.as_slice())?; 131 | Ok(*config) 132 | } 133 | 134 | pub async fn get_clock(&self) -> Result { 135 | let rpc_client = &self.rpc_client; 136 | let data = rpc_client.get_account_data(&sysvar::clock::id()).await?; 137 | bincode::deserialize(&data).map_err(From::from) 138 | } 139 | 140 | fn keypair() -> Result { 141 | let keypair_path = Operator::keypair_path()?; 142 | let keypair = Keypair::read_from_file(keypair_path) 143 | .map_err(|err| Error::Internal(err.to_string()))?; 144 | Ok(keypair) 145 | } 146 | 147 | fn keypair_path() -> Result { 148 | std::env::var("KEYPAIR_PATH").map_err(From::from) 149 | } 150 | 151 | fn rpc_client() -> Result { 152 | let rpc_url = Operator::rpc_url()?; 153 | Ok(RpcClient::new_with_commitment( 154 | rpc_url, 155 | CommitmentConfig::confirmed(), 156 | )) 157 | } 158 | 159 | fn jito_client() -> RpcClient { 160 | let rpc_url = "https://mainnet.block-engine.jito.wtf/api/v1/transactions"; 161 | RpcClient::new_with_commitment(rpc_url.to_string(), CommitmentConfig::confirmed()) 162 | } 163 | 164 | fn rpc_url() -> Result { 165 | std::env::var("RPC_URL").map_err(From::from) 166 | } 167 | 168 | fn operator_commission() -> Result { 169 | let str = std::env::var("OPERATOR_COMMISSION")?; 170 | let commission: u64 = str.parse()?; 171 | Ok(commission) 172 | } 173 | } 174 | -------------------------------------------------------------------------------- /server/src/tx/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod submit; 2 | pub mod validate; 3 | -------------------------------------------------------------------------------- /server/src/tx/submit.rs: -------------------------------------------------------------------------------- 1 | use solana_client::nonblocking::rpc_client::RpcClient; 2 | use solana_sdk::{ 3 | commitment_config::CommitmentConfig, 4 | compute_budget::ComputeBudgetInstruction, 5 | instruction::Instruction, 6 | pubkey::Pubkey, 7 | signature::{Keypair, Signature}, 8 | signer::Signer, 9 | transaction::Transaction, 10 | }; 11 | 12 | use crate::error::Error; 13 | 14 | const JITO_TIP_AMOUNT: u64 = 2_000; 15 | pub const JITO_TIP_ADDRESSES: [Pubkey; 8] = [ 16 | solana_sdk::pubkey!("96gYZGLnJYVFmbjzopPSU6QiEV5fGqZNyN9nmNhvrZU5"), 17 | solana_sdk::pubkey!("HFqU5x63VTqvQss8hp11i4wVV8bD44PvwucfZ2bU7gRe"), 18 | solana_sdk::pubkey!("Cw8CFyM9FkoMi7K7Crf6HNQqf4uEMzpKw6QNghXLvLkY"), 19 | solana_sdk::pubkey!("ADaUMid9yfUytqMBgopwjb2DTLSokTSzL1zt6iGPaS49"), 20 | solana_sdk::pubkey!("DfXygSm4jCyNCybVYYK6DwvWqjKee8pbDmJGcLWNDXjh"), 21 | solana_sdk::pubkey!("ADuUkR4vqLUMWXxW9gh6D6L8pMSawimctcNZ5pGwDcEt"), 22 | solana_sdk::pubkey!("DttWaMuVvTiduZRnguLF7jNxTgiMBZ1hyAumKUiL2KRL"), 23 | solana_sdk::pubkey!("3AVi9Tg9Uo68tJfuvoKvqKNWKkC5wPdSSdeBnizKZ6jT"), 24 | ]; 25 | 26 | pub async fn submit_and_confirm_instructions( 27 | signer: &Keypair, 28 | rpc_client: &RpcClient, 29 | jito_client: &RpcClient, 30 | ixs: &[Instruction], 31 | cu_limit: u32, 32 | cu_price: u64, 33 | ) -> Result { 34 | let max_retries = 5; 35 | let mut retries = 0; 36 | while retries < max_retries { 37 | let sig = 38 | submit_instructions(signer, rpc_client, jito_client, ixs, cu_limit, cu_price).await; 39 | match sig { 40 | Ok(sig) => match confirm_transaction(rpc_client, &sig).await { 41 | Ok(()) => return Ok(sig), 42 | Err(err) => { 43 | log::error!("failed to confirm signature: {:?}", err); 44 | retries += 1; 45 | tokio::time::sleep(tokio::time::Duration::from_secs(2)).await; 46 | } 47 | }, 48 | Err(err) => { 49 | log::error!("failed to submit transaction: {:?}", err); 50 | retries += 1; 51 | tokio::time::sleep(tokio::time::Duration::from_secs(2)).await; 52 | } 53 | } 54 | } 55 | Err(Error::Internal( 56 | "failed to land transaction with confirmation".to_string(), 57 | )) 58 | } 59 | 60 | pub async fn submit_instructions( 61 | signer: &Keypair, 62 | rpc_client: &RpcClient, 63 | jito_client: &RpcClient, 64 | ixs: &[Instruction], 65 | cu_limit: u32, 66 | cu_price: u64, 67 | ) -> Result { 68 | let cu_limit_ix = ComputeBudgetInstruction::set_compute_unit_limit(cu_limit); 69 | let cu_price_ix = ComputeBudgetInstruction::set_compute_unit_price(cu_price); 70 | let tip_ix = tip_ix(&signer.pubkey()); 71 | let final_ixs = &[cu_limit_ix, cu_price_ix]; 72 | let final_ixs = [final_ixs, ixs, &[tip_ix]].concat(); 73 | let hash = rpc_client.get_latest_blockhash().await?; 74 | let mut tx = Transaction::new_with_payer(final_ixs.as_slice(), Some(&signer.pubkey())); 75 | tx.sign(&[signer], hash); 76 | let sim = rpc_client.simulate_transaction(&tx).await?; 77 | log::info!("sim: {:?}", sim); 78 | jito_client.send_transaction(&tx).await.map_err(From::from) 79 | } 80 | 81 | pub async fn submit_and_confirm_transaction( 82 | rpc_client: &RpcClient, 83 | tx: &Transaction, 84 | ) -> Result { 85 | let max_retries = 5; 86 | let mut retries = 0; 87 | while retries < max_retries { 88 | let sig = rpc_client.send_transaction(tx).await; 89 | match sig { 90 | Ok(sig) => match confirm_transaction(rpc_client, &sig).await { 91 | Ok(()) => return Ok(sig), 92 | Err(err) => { 93 | log::info!("{:?}", err); 94 | retries += 1; 95 | tokio::time::sleep(tokio::time::Duration::from_secs(2)).await; 96 | } 97 | }, 98 | Err(err) => { 99 | log::info!("{:?}", err); 100 | retries += 1; 101 | tokio::time::sleep(tokio::time::Duration::from_secs(2)).await; 102 | } 103 | } 104 | } 105 | Err(Error::Internal( 106 | "failed to land transaction with confirmation".to_string(), 107 | )) 108 | } 109 | 110 | async fn confirm_transaction(rpc_client: &RpcClient, sig: &Signature) -> Result<(), Error> { 111 | // Confirm the transaction with retries 112 | let max_retries = 10; 113 | let mut retries = 0; 114 | while retries < max_retries { 115 | if let Ok(confirmed) = rpc_client 116 | .confirm_transaction_with_commitment(sig, CommitmentConfig::confirmed()) 117 | .await 118 | { 119 | if confirmed.value { 120 | break; 121 | } 122 | } 123 | retries += 1; 124 | tokio::time::sleep(tokio::time::Duration::from_secs(2)).await; 125 | } 126 | if retries == max_retries { 127 | return Err(Error::Internal("could not confirm transaction".to_string())); 128 | } 129 | Ok(()) 130 | } 131 | 132 | fn tip_ix(signer: &Pubkey) -> Instruction { 133 | let address = get_jito_tip_address(); 134 | solana_sdk::system_instruction::transfer(signer, &address, JITO_TIP_AMOUNT) 135 | } 136 | 137 | fn get_jito_tip_address() -> Pubkey { 138 | let random_index = rand::random::() % JITO_TIP_ADDRESSES.len(); 139 | JITO_TIP_ADDRESSES[random_index] 140 | } 141 | -------------------------------------------------------------------------------- /server/src/tx/validate.rs: -------------------------------------------------------------------------------- 1 | use crate::tx::submit::JITO_TIP_ADDRESSES; 2 | use ore_pool_api::{instruction::Attribute, prelude::PoolInstruction}; 3 | use solana_sdk::pubkey; 4 | use solana_sdk::pubkey::Pubkey; 5 | use solana_sdk::{ 6 | program_error::ProgramError, system_instruction, system_program, transaction::Transaction, 7 | }; 8 | use spl_associated_token_account::ID as SPL_ASSOCIATED_TOKEN_ID; 9 | 10 | use crate::error::Error; 11 | 12 | /// Lighthouse protocol pubkey, 13 | /// web-browser wallets typically insert these instructions. 14 | const LH_PUBKEY: Pubkey = pubkey!("L2TExMFKdjpN9kozasaurPirfHy9P8sbXoAN1qA3S95"); 15 | 16 | pub fn validate_attribution( 17 | transaction: &Transaction, 18 | member_authority: Pubkey, 19 | pool: Pubkey, 20 | total_balance: i64, 21 | ) -> Result<(), Error> { 22 | let instructions = &transaction.message.instructions; 23 | let n = instructions.len(); 24 | 25 | // Transaction must have at least one instruction 26 | if n < 1 { 27 | return Err(Error::Internal( 28 | "transaction must contain at least one instruction".to_string(), 29 | )); 30 | } 31 | 32 | // Find the index of the first instruction that is neither compute budget nor lighthouse 33 | let mut first_non_allowed_prefix_idx = 0; 34 | while first_non_allowed_prefix_idx < n { 35 | let ix = &instructions[first_non_allowed_prefix_idx]; 36 | let program_id = transaction 37 | .message 38 | .account_keys 39 | .get(ix.program_id_index as usize) 40 | .ok_or(Error::Internal("missing program id".to_string()))?; 41 | 42 | // Allow both compute budget and lighthouse instructions at the beginning 43 | if program_id.ne(&solana_sdk::compute_budget::id()) && program_id.ne(&LH_PUBKEY) { 44 | break; 45 | } 46 | 47 | first_non_allowed_prefix_idx += 1; 48 | } 49 | 50 | // After compute budget and lighthouse instructions, we need at least one instruction (attribution) 51 | let remaining_instructions = n - first_non_allowed_prefix_idx; 52 | if remaining_instructions < 1 { 53 | return Err(Error::Internal( 54 | "transaction must contain at least one instruction after compute budget and lighthouse instructions".to_string(), 55 | )); 56 | } 57 | 58 | // Validate the first non-compute budget/lighthouse instruction as an ore pool attribution instruction 59 | let attr_idx = first_non_allowed_prefix_idx; 60 | let attr_ix = &instructions[attr_idx]; 61 | let attr_program_id = transaction 62 | .message 63 | .account_keys 64 | .get(attr_ix.program_id_index as usize) 65 | .ok_or(Error::Internal( 66 | "missing program id for attribution instruction".to_string(), 67 | ))?; 68 | 69 | if attr_program_id.ne(&ore_pool_api::id()) { 70 | return Err(Error::Internal( 71 | "first instruction after compute budget and lighthouse instructions must be an ore_pool instruction".to_string(), 72 | )); 73 | } 74 | 75 | // Validate that it's specifically an attribution instruction 76 | let attr_data = attr_ix.data.as_slice(); 77 | let (attr_tag, attr_data) = attr_data 78 | .split_first() 79 | .ok_or(ProgramError::InvalidInstructionData)?; 80 | let attr_tag = 81 | PoolInstruction::try_from(*attr_tag).or(Err(ProgramError::InvalidInstructionData))?; 82 | if attr_tag.ne(&PoolInstruction::Attribute) { 83 | return Err(Error::Internal( 84 | "first instruction after compute budget and lighthouse instructions must be an attribution instruction".to_string(), 85 | )); 86 | } 87 | 88 | // Validate attribution amount 89 | let args = Attribute::try_from_bytes(attr_data)?; 90 | let args_total_balance = u64::from_le_bytes(args.total_balance); 91 | if args_total_balance.gt(&(total_balance as u64)) { 92 | return Err(Error::Internal("invalid total balance arg".to_string())); 93 | } 94 | 95 | // Validate attribution member authority 96 | // 97 | // The fifth account in the attribution instruction is the member account 98 | let member_authority_index = attr_ix.accounts.get(4).ok_or(Error::MemberDoesNotExist)?; 99 | let member_parsed = transaction 100 | .message 101 | .account_keys 102 | .get((*member_authority_index) as usize) 103 | .ok_or(Error::MemberDoesNotExist)?; 104 | let (member_pda, _) = ore_pool_api::state::member_pda(member_authority, pool); 105 | if member_pda.ne(member_parsed) { 106 | return Err(Error::Internal( 107 | "payload and instruction member accounts do not match".to_string(), 108 | )); 109 | } 110 | 111 | // Check for subsequent instructions after the attribution instruction 112 | let mut ore_pool_end_idx = first_non_allowed_prefix_idx + 1; 113 | 114 | // Check for an spl-associated-token-account instruction that might come before the claim 115 | if ore_pool_end_idx < n { 116 | let next_ix = &instructions[ore_pool_end_idx]; 117 | let next_program_id = transaction 118 | .message 119 | .account_keys 120 | .get(next_ix.program_id_index as usize) 121 | .ok_or(Error::Internal( 122 | "missing program id for instruction after attribution".to_string(), 123 | ))?; 124 | 125 | // If the next instruction is from spl-associated-token-account, advance the index 126 | if next_program_id.eq(&SPL_ASSOCIATED_TOKEN_ID) { 127 | log::info!("Found spl-associated-token-account instruction"); 128 | ore_pool_end_idx += 1; 129 | } 130 | } 131 | 132 | // Check for a claim instruction (which may come after spl-associated-token-account) 133 | if ore_pool_end_idx < n { 134 | let claim_ix = &instructions[ore_pool_end_idx]; 135 | let claim_program_id = transaction 136 | .message 137 | .account_keys 138 | .get(claim_ix.program_id_index as usize) 139 | .ok_or(Error::Internal( 140 | "missing program id for potential claim instruction".to_string(), 141 | ))?; 142 | 143 | // If the instruction is from ore_pool, validate it as a claim instruction 144 | if claim_program_id.eq(&ore_pool_api::id()) { 145 | // Validate as specifically a claim instruction 146 | let claim_data = claim_ix.data.as_slice(); 147 | let (claim_tag, _claim_data) = claim_data 148 | .split_first() 149 | .ok_or(ProgramError::InvalidInstructionData)?; 150 | let claim_tag = PoolInstruction::try_from(*claim_tag) 151 | .or(Err(ProgramError::InvalidInstructionData))?; 152 | if claim_tag.ne(&PoolInstruction::Claim) { 153 | return Err(Error::Internal( 154 | "ore_pool instruction after attribution must be a claim instruction" 155 | .to_string(), 156 | )); 157 | } 158 | 159 | ore_pool_end_idx += 1; 160 | } 161 | } 162 | 163 | // Validate any remaining instructions belong to lighthouse program or are Jito tips 164 | for i in ore_pool_end_idx..n { 165 | let ix = &instructions[i]; 166 | let program_id_index = ix.program_id_index as usize; 167 | let program_id = transaction 168 | .message 169 | .account_keys 170 | .get(program_id_index) 171 | .ok_or(Error::Internal( 172 | format!("missing program id for instruction at index {}", i).to_string(), 173 | ))?; 174 | 175 | // Allow lighthouse instructions 176 | if program_id.eq(&LH_PUBKEY) { 177 | continue; 178 | } 179 | 180 | // Allow system program transfer to Jito tip addresses 181 | if program_id.eq(&system_program::id()) { 182 | // Check if it's a transfer instruction by attempting deserialization 183 | if let Ok(system_instruction::SystemInstruction::Transfer { lamports: _ }) = 184 | bincode::deserialize(&ix.data) 185 | { 186 | // Get the destination account index (second account for transfer) 187 | if let Some(to_account_index) = ix.accounts.get(1) { 188 | // Get the destination pubkey from the transaction message's account keys 189 | if let Some(to_pubkey) = transaction 190 | .message 191 | .account_keys 192 | .get(*to_account_index as usize) 193 | { 194 | // Check if the destination is a known Jito tip address 195 | if JITO_TIP_ADDRESSES.contains(to_pubkey) { 196 | log::debug!("Allowing Jito tip transfer instruction at index {}", i); 197 | continue; // It's a valid Jito tip, allow it 198 | } 199 | } 200 | } 201 | } 202 | } 203 | 204 | // If it's neither lighthouse nor a valid Jito tip transfer, return error 205 | return Err(Error::Internal( 206 | format!( 207 | "instruction at index {} must belong to lighthouse program or be a Jito tip transfer. Found program id: {}", 208 | i, program_id 209 | ) 210 | .to_string(), 211 | )); 212 | } 213 | 214 | Ok(()) 215 | } 216 | -------------------------------------------------------------------------------- /server/src/utils.rs: -------------------------------------------------------------------------------- 1 | use actix_cors::Cors; 2 | use actix_web::http::header; 3 | 4 | pub fn create_cors() -> Cors { 5 | Cors::default() 6 | .allowed_origin_fn(|_origin, _req_head| { 7 | // origin.as_bytes().ends_with(b"ore.supply") || // Production origin 8 | // origin == "http://localhost:8080" // Local development origin 9 | true 10 | }) 11 | .allowed_methods(vec!["GET", "POST"]) // Methods you want to allow 12 | .allowed_headers(vec![header::AUTHORIZATION, header::ACCEPT]) 13 | .allowed_header(header::CONTENT_TYPE) 14 | .max_age(3600) 15 | } 16 | -------------------------------------------------------------------------------- /server/src/webhook.rs: -------------------------------------------------------------------------------- 1 | use std::{collections::HashMap, str::FromStr}; 2 | 3 | use actix_web::{web, HttpRequest, HttpResponse, Responder}; 4 | use base64::{prelude::BASE64_STANDARD, Engine}; 5 | use cached::proc_macro::cached; 6 | use solana_sdk::signature::Signature; 7 | 8 | use crate::{contributions::PoolMiningEvent, error::Error}; 9 | 10 | #[derive(serde::Deserialize, Debug)] 11 | #[serde(rename_all = "camelCase")] 12 | pub struct RawPayload { 13 | pub block_time: u64, 14 | pub slot: u64, 15 | pub meta: Meta, 16 | pub transaction: Transaction, 17 | } 18 | 19 | #[derive(serde::Deserialize, Debug)] 20 | #[serde(rename_all = "camelCase")] 21 | pub struct Meta { 22 | pub log_messages: Vec, 23 | } 24 | 25 | #[derive(serde::Deserialize, Debug)] 26 | #[serde(rename_all = "camelCase")] 27 | pub struct Transaction { 28 | pub signatures: Vec, 29 | } 30 | 31 | pub async fn mine_event( 32 | tx: web::Data>, 33 | req: HttpRequest, 34 | bytes: web::Bytes, 35 | ) -> impl Responder { 36 | // Validate auth header 37 | if let Err(err) = auth(&req) { 38 | log::error!("{:?}", err); 39 | return HttpResponse::Unauthorized().finish(); 40 | } 41 | 42 | // Parse payload 43 | let bytes = bytes.to_vec(); 44 | let json = match serde_json::from_slice::(bytes.as_slice()) { 45 | Ok(json) => json, 46 | Err(err) => { 47 | log::error!("{:?}", err); 48 | return HttpResponse::BadRequest().finish(); 49 | } 50 | }; 51 | let payload = match serde_json::from_value::>(json) { 52 | Ok(payload) => payload, 53 | Err(err) => { 54 | log::error!("{:?}", err); 55 | return HttpResponse::BadRequest().finish(); 56 | } 57 | }; 58 | let payload = payload.first().unwrap(); 59 | 60 | // Parse mine event from transaction logs 61 | let mine_event = match parse_mine_event(&payload) { 62 | Ok(event) => event, 63 | Err(err) => { 64 | log::error!("{:?}", err); 65 | return HttpResponse::BadRequest().finish(); 66 | } 67 | }; 68 | 69 | // Submit mine event to aggregator 70 | let event = PoolMiningEvent { 71 | signature: Signature::from_str(payload.transaction.signatures.first().unwrap()).unwrap(), 72 | block: payload.slot, 73 | timestamp: payload.block_time, 74 | mine_event: mine_event.clone(), 75 | member_rewards: HashMap::new(), 76 | member_scores: HashMap::new(), 77 | }; 78 | if let Err(err) = tx.send(event).await { 79 | log::error!("{:?}", err); 80 | return HttpResponse::InternalServerError().finish(); 81 | } 82 | 83 | // Return success 84 | HttpResponse::Ok().finish() 85 | } 86 | 87 | 88 | /// Parse a MineEvent from a Helius webhook event 89 | fn parse_mine_event( 90 | payload: &RawPayload, 91 | ) -> Result { 92 | // Find return data string 93 | let log_messages = payload.meta.log_messages.as_slice(); 94 | let prefix = format!("Program return: {} ", ore_pool_api::ID.to_string()); 95 | let mut mine_event_str = ""; 96 | for log_message in log_messages.iter().rev() { 97 | if log_message.starts_with(&prefix) { 98 | mine_event_str = log_message.trim_start_matches(&prefix); 99 | break; 100 | } 101 | } 102 | if mine_event_str.is_empty() { 103 | return Err(Error::Internal("webhook event missing return data".to_string())); 104 | } 105 | 106 | // Parse return data 107 | let mine_event = BASE64_STANDARD.decode(mine_event_str)?; 108 | let mine_event: &ore_api::event::MineEvent = 109 | bytemuck::try_from_bytes(mine_event.as_slice()) 110 | .map_err(|e| Error::Internal(e.to_string()))?; 111 | Ok(*mine_event) 112 | } 113 | 114 | /// Validate the auth header 115 | fn auth(req: &HttpRequest) -> Result<(), Error> { 116 | let header = req.headers().get("Authorization").ok_or(Error::Internal( 117 | "missing auth header in webhook event".to_string(), 118 | ))?; 119 | let header = header.to_str()?; 120 | if header.to_string().ne(&helius_auth_token()) { 121 | return Err(Error::Internal( 122 | "invalid auth header in webhook event".to_string(), 123 | )); 124 | } 125 | Ok(()) 126 | } 127 | 128 | #[cached] 129 | fn helius_auth_token() -> String { 130 | std::env::var("HELIUS_AUTH_TOKEN").expect("No helius auth token found") 131 | } 132 | -------------------------------------------------------------------------------- /types/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "ore-pool-types" 3 | description = "Types for interacting with the API of a pool server" 4 | version.workspace = true 5 | edition.workspace = true 6 | license.workspace = true 7 | homepage.workspace = true 8 | documentation.workspace = true 9 | repository.workspace = true 10 | readme.workspace = true 11 | keywords.workspace = true 12 | 13 | [dependencies] 14 | drillx.workspace = true 15 | serde.workspace = true 16 | solana-sdk.workspace = true 17 | -------------------------------------------------------------------------------- /types/src/lib.rs: -------------------------------------------------------------------------------- 1 | use drillx::Solution; 2 | use serde::{Deserialize, Serialize}; 3 | use solana_sdk::{hash::Hash, pubkey::Pubkey, signature::Signature, transaction::Transaction}; 4 | 5 | #[derive(Debug, Deserialize, Serialize)] 6 | pub struct RegisterPayload { 7 | /// The authority of the member account sending the payload. 8 | pub authority: Pubkey, 9 | } 10 | 11 | #[derive(Debug, Deserialize)] 12 | pub struct GetMemberPayload { 13 | /// The authority of the member account sending the payload. 14 | pub authority: String, 15 | } 16 | 17 | #[derive(Debug, Deserialize)] 18 | pub struct GetEventPayload { 19 | /// The authority of the member account sending the payload. 20 | pub authority: String, 21 | } 22 | 23 | #[derive(Debug, Deserialize)] 24 | pub struct GetChallengePayload { 25 | /// The authority of the member account sending the payload. 26 | pub authority: String, 27 | } 28 | 29 | #[derive(Debug, Deserialize, Serialize)] 30 | pub struct ContributePayload { 31 | /// The authority of the member account sending the payload. 32 | pub authority: Pubkey, 33 | 34 | /// The solution submitted. 35 | pub solution: Solution, 36 | 37 | /// Must be a valid signature of the solution 38 | pub signature: Signature, 39 | } 40 | 41 | #[derive(Debug, Deserialize, Serialize)] 42 | pub struct ContributePayloadV2 { 43 | /// The authority of the member account sending the payload. 44 | pub authority: Pubkey, 45 | 46 | /// The solution submitted. 47 | pub solution: Solution, 48 | } 49 | 50 | #[derive(Debug, Deserialize, Serialize)] 51 | pub struct UpdateBalancePayload { 52 | /// The authority of the member account sending the payload. 53 | pub authority: Pubkey, 54 | 55 | /// The transaction containing the attribute instruction 56 | /// signed by the client as fee payer. 57 | pub transaction: Transaction, 58 | 59 | /// The hash used to signed the transaction on the client. 60 | pub hash: Hash, 61 | } 62 | 63 | #[derive(Debug, Deserialize, Serialize)] 64 | pub struct RegisterStakerPayload { 65 | /// The authority of the member account sending the payload. 66 | pub authority: Pubkey, 67 | 68 | /// The mint for the boost account the member is staking to. 69 | pub mint: Pubkey, 70 | } 71 | 72 | #[derive(Debug, Deserialize, Serialize)] 73 | pub struct PoolAddress { 74 | /// The pubkey address of the pool pda of this operator. 75 | pub address: Pubkey, 76 | 77 | /// The bump returned when deriving the pda. 78 | pub bump: u8, 79 | } 80 | 81 | #[derive(Debug, Serialize, Deserialize, Copy, Clone)] 82 | pub struct Challenge { 83 | /// The current challenge the pool is accepting solutions for. 84 | pub challenge: [u8; 32], 85 | 86 | /// Foreign key to the ORE proof account. 87 | pub lash_hash_at: i64, 88 | 89 | // The current minimum difficulty accepted by the ORE program. 90 | pub min_difficulty: u64, 91 | 92 | // The cutoff time to stop accepting contributions. 93 | pub cutoff_time: u64, 94 | } 95 | 96 | /// The member record that sits in the operator database 97 | #[derive(Debug, Serialize, Deserialize, Clone)] 98 | pub struct Member { 99 | /// The respective pda pubkey of the on-chain account. 100 | pub address: String, 101 | 102 | /// The id as assigned by the on-chain program. 103 | pub id: i64, 104 | 105 | /// The authority pubkey of this account. 106 | pub authority: String, 107 | 108 | /// The pool pubkey this account belongs to. 109 | pub pool_address: String, 110 | 111 | /// The total balance assigned to this account. 112 | /// Always increments for idempotent on-chain updates. 113 | pub total_balance: i64, 114 | 115 | /// Whether or not this member is approved by the operator. 116 | pub is_approved: bool, 117 | 118 | /// Whether or not this member is KYC'd by the operator. 119 | pub is_kyc: bool, 120 | 121 | /// Whether or not this member's on-chain balance is in sync with the operator db balance. 122 | pub is_synced: bool, 123 | } 124 | 125 | /// The staker record that sits in the operator database 126 | #[derive(Debug, Serialize, Deserialize)] 127 | pub struct Staker { 128 | /// the share account address 129 | pub address: Pubkey, 130 | 131 | /// the member id (foreign key relation to members table) 132 | pub member_id: u64, 133 | 134 | /// the mint of the boost account the member is staking to 135 | pub mint: Pubkey, 136 | 137 | /// whether or not this account has been added to the webhook 138 | pub webhook: bool, 139 | } 140 | 141 | /// The response from the /challenge request. 142 | #[derive(Debug, Serialize, Deserialize, Copy, Clone)] 143 | pub struct MemberChallenge { 144 | /// The challenge to mine for. 145 | pub challenge: Challenge, 146 | 147 | /// The number of total members to divide the nonce space by. 148 | pub num_total_members: u64, 149 | 150 | /// The id/index for distinguishing devices the client is using. 151 | #[deprecated( 152 | since = "1.2.0", 153 | note = "The pool server no longer automatically assigns device IDs. Miners should set their device IDs manually." 154 | )] 155 | pub device_id: u8, 156 | 157 | /// The number of client devices permitted per member. 158 | pub num_devices: u8, 159 | 160 | /// The unix timestamp from the onchain clock. 161 | pub unix_timestamp: i64, 162 | } 163 | 164 | /// The response from the update-balance request. 165 | #[derive(Debug, Serialize, Deserialize)] 166 | pub struct BalanceUpdate { 167 | /// The balance updated on-chain. 168 | pub balance: u64, 169 | 170 | /// The transaction signature. 171 | pub signature: Signature, 172 | } 173 | 174 | #[derive(Debug, Serialize, Deserialize)] 175 | pub struct PoolMemberMiningEvent { 176 | pub signature: Signature, 177 | pub block: u64, 178 | pub timestamp: u64, 179 | pub balance: u64, 180 | pub difficulty: u64, 181 | pub last_hash_at: i64, 182 | pub timing: i64, 183 | pub net_reward: u64, 184 | pub net_base_reward: u64, 185 | pub net_miner_boost_reward: u64, 186 | pub net_staker_boost_reward: u64, 187 | pub member_difficulty: u64, 188 | pub member_reward: u64, 189 | } 190 | --------------------------------------------------------------------------------