cow-test-cli 0.5.0

Development and testing CLI for driving the CoW Protocol Solana settlement program.
use anyhow::Context as _;
use clap::{Args as ClapArgs, Parser};
use cow_settlement_client::{
    cow_settlement_interface::{
        data::intent::{Asset, Flags, OrderIntent, OrderKind, TokenAsset},
        pda::order::find_order_pda,
    },
    instruction::CreateOrder,
};
use solana_sdk::{signature::Signer, transaction::Transaction};
use std::num::NonZeroU64;
use std::time::{SystemTime, UNIX_EPOCH};

use super::Context;
use crate::utils::{self, output::print_summary, token::TokenAccountDetails};

#[derive(ClapArgs)]
struct CommonArgs {
    /// Unix timestamp after which the order expires (defaults to 5 minutes from now)
    #[arg(long, default_value_t = valid_to_in(300))]
    valid_to: u32,

    /// Allow partial fills across multiple settlements
    #[arg(long)]
    partially_fillable: bool,
}

#[derive(Parser)]
#[command(long_about = "\
Create an order to sell or buy exactly N token, per the subcommand used \
(`cow sell` / `cow buy`). This only creates the order on-chain; it does not \
itself execute the swap. Supported forms:

  cow sell 1.0 SOL USDC             sell exactly 1.0 SOL, receive any USDC
  cow sell 1.0 SOL 50.0 USDC        sell exactly 1.0 SOL, receive ≥ 50.0 USDC
  cow sell 1.0 SOL for 50.0 USDC    same as before, but more english
  cow sell 1.0 USDC                 sell 1.0 USDC into SOL (SOL implied as buy token)
  cow buy 1.0 SOL 100.0 USDC        buy exactly 1.0 SOL, spend at most 100.0 USDC
  cow buy 1.0 SOL for 100.0 USDC    same as before, but more english
  cow buy 1.0 SOL USDC              buy exactly 1.0 SOL, spending any USDC
  cow buy 1.0 USDC                  buy 1.0 USDC, selling any amount of SOL (implied)

Tokens can be a builtin symbol (SOL, WSOL, USDC), a mint address, or a token-account address.")]
pub struct BuyOrSellArgs {
    /// Remaining terms (tokens and/or an amount) — run with --help for supported forms
    #[arg(num_args = 2..=5)]
    terms: Vec<String>,

    #[command(flatten)]
    common: CommonArgs,
}

pub fn run_sell(ctx: Context, args: BuyOrSellArgs) -> anyhow::Result<()> {
    let BuyOrSellArgs { terms, common } = args;
    let parsed = parse(&ctx, OrderKind::Sell, &terms)?;
    execute(ctx, parsed, common)
}

pub fn run_buy(ctx: Context, args: BuyOrSellArgs) -> anyhow::Result<()> {
    let BuyOrSellArgs { terms, common } = args;
    let parsed = parse(&ctx, OrderKind::Buy, &terms)?;
    execute(ctx, parsed, common)
}

/// Fully resolved and in sell/buy order — `parse` applies the buy-side flip
/// and does token/amount resolution, so `execute` just builds instructions.
struct ParsedOrder {
    kind: OrderKind,
    sell: TokenAccountDetails,
    sell_amount: NonZeroU64,
    sell_is_sol: bool,
    buy: TokenAccountDetails,
    buy_amount: NonZeroU64,
}

/// Resolve a side's amount. An explicit amount must be non-zero (a typed `0`
/// errors); an omitted one (the open-ended forms, `None`) defaults to 1, the
/// smallest limit a non-zero order allows.
fn resolve_amount(amount: Option<&str>, decimals: u8) -> anyhow::Result<NonZeroU64> {
    let Some(amount) = amount else {
        return Ok(NonZeroU64::MIN);
    };
    let raw = spl_token::try_ui_amount_into_amount(amount.to_string(), decimals)
        .map_err(|_| anyhow::anyhow!("invalid value {amount:?}"))?;
    NonZeroU64::new(raw).context("must be non-zero")
}

fn parse(ctx: &Context, kind: OrderKind, terms: &[String]) -> anyhow::Result<ParsedOrder> {
    let term_refs: Vec<&str> = terms.iter().map(String::as_str).collect();
    let terms = &term_refs[..];

    // first two terms are always the same
    let [a_amount, a_tok, ..] = terms else {
        anyhow::bail!(
            "must provide at least an amount and a token; run `cow sell --help` for usage"
        );
    };

    // next token could be "for" or "with" (ignored), a second token, or a numeric amount

    // a/b are in the order the user typed them, not sell/buy order. The counter
    // amount is optional: an omitted one (the open-ended forms) is `None`.
    let (b_tok, b_amount): (&str, Option<&str>) = match terms[2..] {
        [] => ("SOL", None),
        ["for" | "with", b] => (b, None),
        ["for" | "with", buy_amount, b] => (b, Some(buy_amount)),
        [b] => (b, None),
        [buy_amount, b] => (b, Some(buy_amount)),
        _ => anyhow::bail!(
            "cannot interpret {:?}; run `cow sell --help` for usage",
            terms
        ),
    };

    // The exact side (`a_amount`) is always given; only the counter side can be
    // omitted.
    let (sell_tok, sell_amount_str, buy_tok, buy_amount_str) = match kind {
        OrderKind::Sell => (*a_tok, Some(*a_amount), b_tok, b_amount),
        OrderKind::Buy => (b_tok, b_amount, *a_tok, Some(*a_amount)),
    };

    let sell = utils::token::resolve(&ctx.rpc, &ctx.payer.pubkey(), sell_tok)?;
    let buy = utils::token::resolve(&ctx.rpc, &ctx.payer.pubkey(), buy_tok)?;

    let sell_amount =
        resolve_amount(sell_amount_str, sell.mint_data.decimals).context("sell amount")?;
    let buy_amount =
        resolve_amount(buy_amount_str, buy.mint_data.decimals).context("buy amount")?;

    Ok(ParsedOrder {
        kind,
        sell_is_sol: sell_tok.eq_ignore_ascii_case("sol"),
        sell,
        sell_amount,
        buy,
        buy_amount,
    })
}

fn execute(ctx: Context, parsed: ParsedOrder, common: CommonArgs) -> anyhow::Result<()> {
    let ParsedOrder {
        kind,
        sell,
        sell_amount,
        sell_is_sol,
        buy,
        buy_amount,
    } = parsed;

    // If the sell token is SOL, wrap it into the payer's WSOL ATA first.
    // NOTE: later this will be swapped for the solflow program.
    let mut ixs = Vec::new();

    if sell_is_sol {
        let (wsol_ata, wrap_ixs) =
            utils::spl_instructions::wrap_sol(&ctx.rpc, &ctx.payer.pubkey(), sell_amount.get())?;
        assert_eq!(wsol_ata, sell.handle.account, "resolved WSOL ATA mismatch");
        ixs.extend(wrap_ixs);
    }

    // Create the account on the buy side if necessary
    ixs.extend(buy.create_ata_ix(&ctx.payer.pubkey()));

    // Approve the settlement state PDA to pull sell tokens on the user's behalf.
    ixs.push(utils::spl_instructions::approve(
        &ctx.program_id,
        &sell.handle,
        &ctx.payer.pubkey(),
        sell_amount.get(),
    )?);

    let intent = OrderIntent {
        owner: ctx.payer.pubkey(),
        sell: TokenAsset {
            mint: sell.handle.mint,
            token_account: sell.handle.account,
        },
        buy: Asset::try_from(TokenAsset {
            mint: buy.handle.mint,
            token_account: buy.handle.account,
        })
        .map_err(|_| anyhow::anyhow!("buy mint {} is the native SOL marker", buy.handle.mint))?,
        sell_amount,
        buy_amount,
        valid_to: common.valid_to,
        flags: Flags {
            kind,
            partially_fillable: common.partially_fillable,
        },
        app_data: [0u8; 32],
    };

    let uid = intent.uid();
    let (order_pda, _) = find_order_pda(&ctx.program_id, &uid);

    // owner == created_by: the payer both owns the order and funds the rent.
    let create_order_ix = CreateOrder {
        program_id: ctx.program_id,
        owner: ctx.payer.pubkey(),
        created_by: ctx.payer.pubkey(),
        intent: &intent,
    };

    ixs.push(create_order_ix.into());

    let blockhash = ctx
        .rpc
        .get_latest_blockhash()
        .context("failed to fetch blockhash")?;
    let tx = Transaction::new_signed_with_payer(
        &ixs,
        Some(&ctx.payer.pubkey()),
        &[&ctx.payer],
        blockhash,
    );
    let sig = ctx
        .rpc
        .send_and_confirm_transaction(&tx)
        .context("transaction failed")?;

    let uid_hex: String = uid.as_ref().iter().map(|b| format!("{b:02x}")).collect();

    print_summary(&[
        ("signature", &sig),
        ("orderPda", &order_pda),
        ("orderUid", &uid_hex),
    ]);

    Ok(())
}

/// Returns the current unix timestamp plus `secs_from_now`, saturating at `u32::MAX`.
fn valid_to_in(secs_from_now: u64) -> u32 {
    let now = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap_or_default()
        .as_secs();
    u32::try_from(now.saturating_add(secs_from_now)).unwrap_or(u32::MAX)
}