diff --git a/Cargo.lock b/Cargo.lock index 2920587..f96263a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -882,6 +882,7 @@ dependencies = [ "clap", "settlement-client", "solana-commitment-config", + "solana-hash 3.1.0", "solana-instruction", "solana-program-pack", "solana-pubkey 3.0.0", diff --git a/test-cli/Cargo.toml b/test-cli/Cargo.toml index 3235c23..230cbf6 100644 --- a/test-cli/Cargo.toml +++ b/test-cli/Cargo.toml @@ -12,6 +12,7 @@ anyhow.workspace = true clap.workspace = true settlement-client.workspace = true solana-commitment-config.workspace = true +solana-hash.workspace = true solana-instruction.workspace = true solana-program-pack.workspace = true solana-pubkey.workspace = true diff --git a/test-cli/src/cmd/create_order.rs b/test-cli/src/cmd/create_order.rs index dae1091..f1ad935 100644 --- a/test-cli/src/cmd/create_order.rs +++ b/test-cli/src/cmd/create_order.rs @@ -107,9 +107,9 @@ fn parse(ctx: &Context, kind: OrderKind, terms: &[String]) -> anyhow::Result anyhow::Res if sell_is_sol { let (wsol_ata, wrap_ixs) = crate::instructions::wrap_sol(&ctx.payer.pubkey(), sell_amount)?; - assert_eq!(wsol_ata, sell.account, "resolved WSOL ATA mismatch"); + assert_eq!(wsol_ata, sell.ta, "resolved WSOL ATA mismatch"); ixs.extend(wrap_ixs); } // Approve the settlement program to pull sell tokens on our behalf. ixs.push(crate::instructions::approve( &ctx.program_id, - &sell.account, + &sell.ta, &ctx.payer.pubkey(), sell_amount, )?); let intent = OrderIntent { owner: ctx.payer.pubkey(), - sell_token_account: sell.account, - buy_token_account: buy.account, + sell_token_account: sell.ta, + buy_token_account: buy.ta, sell_amount, buy_amount, valid_to: common.valid_to, diff --git a/test-cli/src/cmd/mod.rs b/test-cli/src/cmd/mod.rs index 9db13b7..d539188 100644 --- a/test-cli/src/cmd/mod.rs +++ b/test-cli/src/cmd/mod.rs @@ -7,6 +7,7 @@ use solana_sdk::signer::keypair::Keypair; use crate::Cli; pub mod create_order; +pub mod settle; /// Shared context threaded through every subcommand. pub struct Context { diff --git a/test-cli/src/cmd/settle.rs b/test-cli/src/cmd/settle.rs new file mode 100644 index 0000000..35fa884 --- /dev/null +++ b/test-cli/src/cmd/settle.rs @@ -0,0 +1,448 @@ +use anyhow::Context as _; +use clap::Args; +use settlement_client::{ + instructions::{ + BeginSettle, CreateBuffers, FinalizeSettle, FinalizedIntent, InitializedIntent, Pull + }, settlement_interface::{ + Pubkey, data::{intent::OrderIntent, order::EncodedOrderAccount}, pda::buffer::find_buffer_pda, + }, +}; +use solana_hash::Hash; +use solana_instruction::Instruction; +use solana_rpc_client::rpc_client::RpcClient; +use solana_sdk::{ + signature::{Signature, Signer}, + transaction::Transaction, +}; +use spl_associated_token_account_interface::{ + address::get_associated_token_address_with_program_id, + instruction::create_associated_token_account_idempotent, +}; +use std::{collections::{HashMap, HashSet}, ops::Add}; + +use crate::token::{ResolvedToken, resolve_token_from_account}; + +use super::Context; + +#[derive(Args)] +pub struct SettleArgs { + /// Order UIDs (64-char hex) or PDA addresses (base58), one or more + #[arg(required = true, num_args = 1..)] + orders: Vec, + + /// Build and print the settlement without sending the transaction on-chain + #[arg(long)] + dry_run: bool, +} + +struct ResolvedIntent { + /// The original order from the user + data: OrderIntent, + + /// Weight for determining how much of the output an order should get + contribution_score: u64, + + /// All the information about the sell account's TA and Mint + sell: ResolvedToken, + + /// All the information about the buy account's TA and Mint + buy: ResolvedToken, +} + +pub fn run(ctx: Context, args: SettleArgs) -> anyhow::Result<()> { + let intents = resolve_intents(&ctx, &args)?; + + let mut all_ixs: Vec = vec![]; + let (sell_amount_pulled, buy_amount_pushed) = + prepare_setup_ixs(&ctx, &args, &intents, &mut all_ixs)?; + + let mut sinks = compute_sinks(&ctx, &sell_amount_pulled, &buy_amount_pushed)?; + + // TODO: later this will be computed by a function that takes into account swap outputs + // but for now the source amount that can be sent as output is just the same as the buffer output + let sources = sinks.iter().map(|(a, s)| (*a, s[0].amount)).collect(); + + let pulls = compute_pulls(&intents, &mut sinks); + + let initialized_intents: Vec<_> = intents + .iter() + .zip(pulls.iter()) + .map(|(intent, pulls)| InitializedIntent { intent: &intent.data, pulls }) + .collect(); + + let begin_ix_index = all_ixs.len() as u16; + let finalize_ix_index = begin_ix_index + .saturating_add(1) + // TODO: later add mid txs + .saturating_add(0u16); + + let begin_ix = BeginSettle { + program_id: ctx.program_id, + finalize_ix_index, + orders: &initialized_intents, + }; + + let push_amounts = compute_push_amounts(&intents, &sources); + + let settled: Vec = intents + .iter() + .zip(push_amounts.iter()) + .map(|(intent, &amount)| FinalizedIntent { + intent: &intent.data, + mint: intent.buy.mint, + amount, + }) + .collect(); + + let finalize_ix = FinalizeSettle { + program_id: ctx.program_id, + begin_ix_index, + orders: &settled, + }; + + all_ixs.push(begin_ix.into()); + all_ixs.push(finalize_ix.into()); + + let sig = if args.dry_run { + None + } else { + Some(send_settle_transaction(&ctx, &all_ixs)?) + }; + print_settlement_summary(sig.as_ref(), &intents, &push_amounts); + + Ok(()) +} + +/// Resolve each order input to its on-chain intent, then resolve the sell/buy +/// token accounts for every order. Sorted largest-sell-first so that later +/// the packing can be a bit more optimal for matching pull destinations with +/// orders that can fill them. +fn resolve_intents(ctx: &Context, args: &SettleArgs) -> anyhow::Result> { + let intents = args + .orders + .iter() + .map(|s| fetch_order_intent(&ctx.rpc, ctx, s)) + .collect::>>()?; + + let mut intents = intents + .into_iter() + .map(|intent| { + Ok(ResolvedIntent { + sell: resolve_token_from_account(&ctx.rpc, &ctx.payer.pubkey(), &intent.sell_token_account)?, + buy: resolve_token_from_account(&ctx.rpc, &ctx.payer.pubkey(), &intent.buy_token_account)?, + + // for now: the contribution score weighting is based on the buy amount the user requests + // clamped to at least 1 in case the user requests 0 in their order to prevent edge cases where all orders have buy_amount 0) + // in a real circumstance, this should be based on the native price of the sell_amount + contribution_score: intent.buy_amount.max(1), + + data: intent, + }) + }) + .collect::>>()?; + + intents.sort_unstable_by_key(|i| std::cmp::Reverse(i.data.sell_amount)); + + Ok(intents) +} + +/// Create any missing signer ATAs and buffer PDAs before the settle tx, and +/// tally up the total sell/buy amount per mint across all orders. +fn prepare_setup_ixs( + ctx: &Context, + args: &SettleArgs, + intents: &[ResolvedIntent], + all_ixs: &mut Vec, +) -> anyhow::Result<(HashMap, HashMap)> { + let mut sell_amount_pulled: HashMap = HashMap::new(); + let mut buy_amount_pushed: HashMap = HashMap::new(); + let mut mint_buffers_to_create: Vec = Vec::new(); + + for (i, intent) in intents.iter().enumerate() { + if !buy_amount_pushed.contains_key(&intent.buy.mint) { + let (buffer_pda, _) = find_buffer_pda(&ctx.program_id, &intent.buy.mint); + if ctx.rpc.get_account(&buffer_pda).is_err() { + mint_buffers_to_create.push(intent.buy.mint); + } + } + + // accumulates the sell and buy amounts (inserts if the key doesn't exist) + let sell_tally_entry = sell_amount_pulled.entry(intent.sell.mint).or_default(); + *sell_tally_entry = sell_tally_entry.saturating_add(intent.data.sell_amount); + let buy_tally_entry = buy_amount_pushed.entry(intent.buy.mint).or_default(); + *buy_tally_entry = buy_tally_entry.saturating_add(intent.data.buy_amount); + + if intent.sell.ta_data.owner == Pubkey::default() { + let named_intent = &args.orders[i]; + let ta = intent.sell.ta; + anyhow::bail!("Order {named_intent}: sell account {ta} does not exist") + } + + if intent.buy.ta_data.owner == Pubkey::default() { + // as of right now, it may be necessary to create the buy token account if it doesn't exist yet + // here we assume it is an associated token account + all_ixs.push(create_associated_token_account_idempotent( + &ctx.payer.pubkey(), + &intent.data.owner, + &intent.buy.mint, + &spl_token_interface::id(), + )); + } + } + + if !mint_buffers_to_create.is_empty() { + all_ixs.push( + CreateBuffers { + program_id: ctx.program_id, + payer: ctx.payer.pubkey(), + mints: &mint_buffers_to_create, + } + .into(), + ); + } + + Ok((sell_amount_pulled, buy_amount_pushed)) +} + +/// Compute aggregate pull destinations that will be needed for a settlement. Includes stuff +/// like buffer PDAs and, once swap routing exists, exchange routes. +fn compute_sinks( + ctx: &Context, + sell_amount_pulled: &HashMap, + buy_amount_pushed: &HashMap, +) -> anyhow::Result>> { + let mut sinks: HashMap> = HashMap::new(); + + // start with CoWs. Create a sink that pulls in tokens that don't need to be + // traded (aka, min(pulled, pushed)) because they are already going to be consumed by another order + // and it is assumed that trading is always less efficient than direct matching. + // this could be 0: if so, it will be populated in the loop later. + for key in sell_amount_pulled.keys().chain(buy_amount_pushed.keys()) { + // entry() avoids computing the difference twice for keys in both maps + sinks.entry(*key).or_insert_with(|| { + let (buffer_pda, _) = find_buffer_pda(&ctx.program_id, key); + vec![Pull { + destination: buffer_pda, + amount: *sell_amount_pulled.get(key).unwrap_or(&0).min(buy_amount_pushed.get(key).unwrap_or(&0)), + }] + }); + } + + // TODO: later we would add exchanges needed for any tokens that still need a swap + + // distribute surplus: + for (sell_token, total_sell_amount) in sell_amount_pulled.iter() { + let d = sinks + .get_mut(sell_token) + .expect("sink seeded for every sell mint in the CoW loop above"); + + let sunk_total: u64 = d.iter().map(|p| p.amount).sum(); + let surplus = total_sell_amount.saturating_sub(sunk_total); + + if surplus > 0 { + // edge case: if no sinks were needed (this could happen if the destination side order + // requested a buy_amount of 0 tokens), send all the surplus to the buffer for distribution + if sunk_total == 0 { + d[0].amount = surplus; + } else { + // we want to send tokens proportionally to each sink + let weights: Vec = d.iter().map(|p| p.amount).collect(); + for (pull, increment) in d.iter_mut().zip(distribute_proportionally(&weights, surplus)) { + pull.amount = pull.amount.saturating_add(increment); + } + } + } + + // finally, sort for more efficient pull matching later + d.sort_unstable_by_key(|p| std::cmp::Reverse(p.amount)); + } + + Ok(sinks) +} + +/// Distribute `extra` proportionally across `weights` (each entry's current +/// amount), using largest-remainder rounding so the increments sum to +/// exactly `extra`. +fn distribute_proportionally(weights: &[u64], extra: u64) -> Vec { + let total_weight: u128 = weights.iter().map(|&w| w as u128).sum(); + let extra128 = extra as u128; + + let mut increments: Vec = weights + .iter() + .map(|&w| extra128.saturating_mul(w as u128).checked_div(total_weight).unwrap_or(0) as u64) + .collect(); + + let sum: u64 = increments.iter().fold(0u64, |acc, &s| acc.saturating_add(s)); + let leftover = extra.saturating_sub(sum) as usize; + if leftover > 0 { + let mut fracs: Vec<(usize, u128)> = weights + .iter() + .enumerate() + .map(|(j, &w)| (j, extra128.saturating_mul(w as u128).checked_rem(total_weight).unwrap_or(0))) + .collect(); + fracs.sort_by_key(|a| std::cmp::Reverse(a.1)); + for k in 0..leftover { + increments[fracs[k].0] = increments[fracs[k].0].saturating_add(1); + } + } + + increments +} + +/// Carve each order's required pull amount out of the shared per-mint sink +/// pool, depleting `sinks` as we go. Whatever remains per mint afterward +/// feeds `compute_push_amounts`. +fn compute_pulls(intents: &[ResolvedIntent], sinks: &mut HashMap>) -> Vec> { + let mut pulls = Vec::with_capacity(intents.len()); + for intent in intents { + let mut p = Vec::with_capacity(1); + + let mut to_pull = intent.data.sell_amount; + sinks.entry(intent.sell.mint).and_modify(|d| { + while to_pull > 0 { + let last = d.len() - 1; + if d[last].amount <= to_pull { + to_pull -= d[last].amount; + p.push(d.pop().unwrap()); + } else { + p.push(Pull { destination: d[last].destination, amount: to_pull }); + d[last].amount = d[last].amount.saturating_sub(to_pull); + to_pull = 0; + } + } + }); + + pulls.push(p); + } + + pulls +} + +/// Computed buy amounts: each order receives from the total available output tokens weighted proportional +/// to its contribution_score +fn compute_push_amounts( + intents: &[ResolvedIntent], + sources: &HashMap +) -> Vec { + let mut orders_by_mint: HashMap> = HashMap::new(); + for (i, intent) in intents.iter().enumerate() { + orders_by_mint.entry(intent.buy.mint).or_default().push(i); + } + + let mut result = vec![0u64; intents.len()]; + + for (mint, indices) in &orders_by_mint { + let avail = *sources.get(mint).unwrap_or(&0); + if avail == 0 { + continue; + } + + // we distribute based on contribution score + // NOTE: technically its possible if contribution_score is not scaled to the expected buy_amount, + // the output token could be insufficient for a user. In this case, it can be decided that that particular + // order would be unsolvable, or it has to truncate. but since right now it *is* based on buy_amount, we are fine with + // this simple calculation + let weights: Vec = indices.iter().map(|&i| intents[i].contribution_score).collect(); + + for (&order_idx, share) in indices.iter().zip(distribute_proportionally(&weights, avail)) { + result[order_idx] = share; + } + } + + result +} + +fn send_settle_transaction(ctx: &Context, all_ixs: &[Instruction]) -> anyhow::Result { + let blockhash = ctx.rpc.get_latest_blockhash().context("fetch blockhash")?; + let tx = + Transaction::new_signed_with_payer(all_ixs, Some(&ctx.payer.pubkey()), &[&ctx.payer], blockhash); + ctx.rpc + .send_and_confirm_transaction(&tx) + .context("settle transaction failed") +} + +fn print_settlement_summary(sig: Option<&Signature>, intents: &[ResolvedIntent], push_amounts: &[u64]) { + match sig { + Some(sig) => println!("settle: {sig}"), + None => println!("settle: dry run (transaction not sent)"), + } + for (i, (intent, &pushed)) in intents.iter().zip(push_amounts.iter()).enumerate() { + println!( + " order {i}: pulled {} (sell {}), pushed {} (buy {}){}", + intent.data.sell_amount, + intent.sell.mint, + pushed, + intent.buy.mint, + if pushed > intent.data.buy_amount { + format!(" [+{} surplus]", pushed.saturating_sub(intent.data.buy_amount)) + } else { + String::new() + }, + ); + } +} + +fn fetch_order_intent( + rpc: &RpcClient, + ctx: &Context, + s: &str, +) -> anyhow::Result { + let pda = parse_order_input(ctx, s)?; + let data = rpc + .get_account_data(&pda) + .with_context(|| format!("order account {pda} not found on-chain"))?; + let bytes: [u8; EncodedOrderAccount::SIZE] = data.as_slice().try_into().map_err(|_| { + anyhow::anyhow!( + "unexpected account data length {} for order at {pda}", + data.len() + ) + })?; + let (order_account, _uid) = EncodedOrderAccount::decode_and_hash(&bytes) + .map_err(|e| anyhow::anyhow!("failed to decode order at {pda}: {e:?}"))?; + Ok(order_account.intent) +} + +/// Accept either a 64-char hex UID or a base58 pubkey (the PDA directly). +fn parse_order_input(ctx: &Context, s: &str) -> anyhow::Result { + if let Ok(pubkey) = s.parse::() { + return Ok(pubkey); + } + anyhow::ensure!( + s.len() == 64, + "expected a base58 order PDA or a 64-char hex UID, got '{s}'" + ); + + // TODO: after a bit of research, this appears to be the most recommended way in std + solana_hash to + // convert a string into a hash. We might want to move this into a proper function later. + let mut bytes = [0u8; 32]; + for (i, piece) in s.as_bytes().chunks(2).enumerate() { + bytes[i] = u8::from_str_radix(std::str::from_utf8(piece).expect("Should return to utf8 string"), 16) + .with_context(|| format!("invalid hex in UID '{s}' at byte {i}"))?; + } + let uid = Hash::new_from_array(bytes); + let (pda, _) = + settlement_client::settlement_interface::pda::order::find_order_pda(&ctx.program_id, &uid); + Ok(pda) +} + +/// # TODO +/// Wire up the Orca Whirlpools SDK (e.g. `orca-whirlpools-client`) to build +/// the real `swap` instruction. The function signature is ready; only the body +/// needs filling in once the dependency is added. For CoW settlements +/// (opposite-direction orders), no swap is needed — call this only when +/// the signer lacks the buy tokens and cannot cover them via CoW matching. +#[allow(dead_code)] +fn orca_swap( + sell_mint: &Pubkey, + buy_mint: &Pubkey, + _input_ata: &Pubkey, + _output_ata: &Pubkey, + _authority: &Pubkey, +) -> anyhow::Result { + anyhow::bail!( + "swap from {} to {} required; Orca Whirlpool integration is not yet implemented — \ + add the orca-whirlpools-client crate and fill in `orca_swap` in settle.rs", + sell_mint, + buy_mint, + ) +} diff --git a/test-cli/src/main.rs b/test-cli/src/main.rs index dc46107..87a2a7f 100644 --- a/test-cli/src/main.rs +++ b/test-cli/src/main.rs @@ -45,6 +45,8 @@ enum Commands { Sell(cmd::create_order::BuyOrSellArgs), #[command(about = "Buy a token using another (e.g. `cow buy 1.0 SOL with USDC`)")] Buy(cmd::create_order::BuyOrSellArgs), + #[command(about = "Settle one or more orders")] + Settle(cmd::settle::SettleArgs), } fn main() -> anyhow::Result<()> { @@ -53,5 +55,6 @@ fn main() -> anyhow::Result<()> { match cli.command { Commands::Sell(args) => cmd::create_order::run_sell(ctx, args), Commands::Buy(args) => cmd::create_order::run_buy(ctx, args), + Commands::Settle(args) => cmd::settle::run(ctx, args), } } diff --git a/test-cli/src/token.rs b/test-cli/src/token.rs index cd18bfc..b2497d8 100644 --- a/test-cli/src/token.rs +++ b/test-cli/src/token.rs @@ -42,9 +42,13 @@ fn known_token(genesis_hash: &str, symbol: &str) -> Option<&'static KnownToken> pub struct ResolvedToken { /// SPL token account to use in the order (ATA if supplied program argument was a mint). - pub account: Pubkey, - /// On-chain `decimals` value for the token's mint. - pub decimals: u8, + pub ta: Pubkey, + /// The actual TokenAccount data + pub ta_data: TokenAccount, + /// Mint address for the token. + pub mint: Pubkey, + /// The actual mint data + pub mint_data: Mint, } /// Resolve a user-supplied token string to a token account and decimal count. @@ -60,14 +64,16 @@ pub fn resolve(rpc: &RpcClient, owner: &Pubkey, token_str: &str) -> anyhow::Resu &spl_token_interface::id(), ); return Ok(ResolvedToken { - account: wsol_ata, - decimals: native_mint::DECIMALS, + ta: wsol_ata, + mint: wsol_mint, + ta_data: fetch_ta_data(rpc, &wsol_ata)?, + mint_data: fetch_mint_data(rpc, &wsol_mint)?, }); } // 2. Base58 mint or token-account address — fetches decimals from the mint, and possibly the token account owner. if let Ok(pubkey) = token_str.parse::() { - return resolve_from_account(rpc, owner, &pubkey); + return resolve_token_from_account(rpc, owner, &pubkey); } // 3. Known symbol (e.g. `"USDC"`) — payer's ATA for the registered mint, RPC call required to get genesis hash (detecting the network). @@ -82,8 +88,10 @@ pub fn resolve(rpc: &RpcClient, owner: &Pubkey, token_str: &str) -> anyhow::Resu &spl_token_interface::id(), ); return Ok(ResolvedToken { - account: ata, - decimals: known.decimals, + ta: ata, + ta_data: fetch_ta_data(rpc, &ata)?, + mint: known.mint, + mint_data: fetch_mint_data(rpc, &known.mint)?, }); } @@ -97,7 +105,7 @@ pub fn resolve(rpc: &RpcClient, owner: &Pubkey, token_str: &str) -> anyhow::Resu /// If a token account is supplied, an additional call is required to retrieve the mint address. /// Then, the mint account data is decoded to retrieve important token information, such as the /// decimals. -fn resolve_from_account( +pub fn resolve_token_from_account( rpc: &RpcClient, owner: &Pubkey, token_account_or_mint: &Pubkey, @@ -114,17 +122,18 @@ fn resolve_from_account( if let Ok(token_account) = TokenAccount::unpack(&account.data) { Ok(ResolvedToken { - account: *token_account_or_mint, - decimals: fetch_mint_decimals(rpc, &token_account.mint)?, + ta: *token_account_or_mint, + mint: token_account.mint, + ta_data: token_account, + mint_data: fetch_mint_data(rpc, &token_account.mint)?, }) } else if let Ok(mint) = Mint::unpack(&account.data) { + let ata = get_associated_token_address_with_program_id(owner, token_account_or_mint, &spl_token_interface::id()); Ok(ResolvedToken { - account: get_associated_token_address_with_program_id( - owner, - token_account_or_mint, - &account.owner, - ), - decimals: mint.decimals, + ta: ata, + ta_data: fetch_ta_data(rpc, &ata)?, + mint_data: mint, + mint: *token_account_or_mint, }) } else { anyhow::bail!( @@ -135,11 +144,34 @@ fn resolve_from_account( } } -fn fetch_mint_decimals(rpc: &RpcClient, mint: &Pubkey) -> anyhow::Result { +fn fetch_ta_data(rpc: &RpcClient, token_account: &Pubkey) -> anyhow::Result { + let data = rpc + .get_account_data(token_account); + + if let Err(_) = data { + return Ok(TokenAccount::default()); + } + + if let Ok(ta_data) = TokenAccount::unpack(&data.unwrap()) { + Ok(ta_data) + } else { + Err(anyhow::anyhow!( + "account {token_account} is not a token account" + )) + } +} + +fn fetch_mint_data(rpc: &RpcClient, mint: &Pubkey) -> anyhow::Result { + let data = rpc .get_account_data(mint) .with_context(|| format!("mint account {mint} not found"))?; - Ok(Mint::unpack(&data) - .with_context(|| format!("failed to unpack mint {mint}"))? - .decimals) + + if let Ok(mint_data) = Mint::unpack(&data) { + Ok(mint_data) + } else { + Err(anyhow::anyhow!( + "account {mint} is not a mint" + )) + } }