Compare commits

...

3 Commits

12 changed files with 3173 additions and 140 deletions

2
.cargo/config.toml Normal file
View File

@ -0,0 +1,2 @@
[registries.distrust]
index = "https://git.distrust.co/public/_cargo-index.git"

2679
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -3,6 +3,10 @@
resolver = "2"
members = [
"crates/icepick",
"crates/icepick-module",
"crates/by-chain/icepick-solana",
]
[workspace.dependencies]
serde = { version= "1.0.195", features = ["derive"] }
serde_json = "1.0.111"

View File

@ -1,3 +1,19 @@
# Icepick
Icepick is a framework for rapidly developing applications to perform transfer
and staking cryptocurrency operations.
## Implementing a New Coin
Coins are implemented using the `icepick_coin::Coin` trait. Implementing the
trait should act as a "flow" for implementing the coin.
The crate will need the `clap` dependency for generating the CLI command and
the `icepick-coin` dependency for implementing the `Coin` trait.
`Coin` should be implemented on marker structs that can be easily placed in the
main Icepick program.
## Bitcoin
Bitcoin is a native coin, and does not require any kind of custom token support

View File

@ -0,0 +1,11 @@
[package]
name = "icepick-solana"
version = "0.1.0"
edition = "2021"
[dependencies]
icepick-module = { version = "0.1.0", path = "../../icepick-module" }
serde = { workspace = true, features = ["derive"] }
serde_json.workspace = true
solana-sdk = { version = "2.1.1", path = "../../../vendor/solana-sdk" }
thiserror = "2.0.3"

View File

@ -0,0 +1,6 @@
use icepick_module::Module;
use icepick_solana::Solana;
fn main() -> Result<(), Box<dyn std::error::Error>> {
Solana::run_responder()
}

View File

@ -0,0 +1,207 @@
use icepick_module::{
help::{Argument, ArgumentType},
Module,
};
use serde::{Deserialize, Serialize};
use std::str::FromStr;
// How does this not exist in solana_sdk.
const LAMPORTS_PER_SOL: u64 = 1_000_000_000;
#[derive(thiserror::Error, Debug)]
pub enum Error {}
#[derive(Serialize, Deserialize, Debug)]
#[serde(rename_all = "kebab-case")]
pub struct Transfer {
amount: String,
blockhash: String,
to_address: String,
from_account: Option<String>,
from_address: String,
fee: Option<String>,
fee_payer: Option<String>,
fee_payer_address: Option<String>,
}
#[derive(Serialize, Deserialize, Debug)]
#[serde(rename_all = "kebab-case")]
pub struct Sign {}
#[derive(Serialize, Deserialize, Debug)]
#[serde(rename_all = "kebab-case")]
pub struct Request {
// NOTE: Can't use the proper XPrv type from Keyfork because Solana's a big stinky
// and adds in its own derivation constructs that cause type conflicts.
derived_keys: Option<Vec<[u8; 32]>>,
// NOTE: This is an opaque type that can be deserialized inside an Operation
blob: Option<serde_json::Value>,
#[serde(flatten)]
operation: Operation,
}
#[derive(Serialize, Deserialize, Debug)]
#[serde(tag = "operation", content = "values", rename_all = "kebab-case")]
pub enum Operation {
Transfer(Transfer),
Sign(Sign),
}
pub struct Solana;
impl Module for Solana {
type Error = Error;
type Request = Request;
fn describe_operations() -> Vec<icepick_module::help::Operation> {
let account = Argument {
name: "from-account".to_string(),
description: "The derivation account used for the transaction.".to_string(),
r#type: ArgumentType::Optional,
};
let fee = Argument {
name: "fee".to_string(),
description: "A custom fee for the transaction".to_string(),
r#type: ArgumentType::Optional,
};
let fee_payer_address = Argument {
name: "fee-payer-address".to_string(),
description: "The address used to pay the fee.".to_string(),
r#type: ArgumentType::Optional,
};
let fee_payer = Argument {
name: "fee-payer".to_string(),
description: "The derivation account used to pay the fee.".to_string(),
r#type: ArgumentType::Optional,
};
let blockhash = Argument {
name: "blockhash".to_string(),
description: "A recent blockhash".to_string(),
r#type: ArgumentType::Required,
};
let from_address = Argument {
name: "from-address".to_string(),
description: concat!(
"The address to send SOL from; will be used to verify ",
"the derivation account."
)
.to_string(),
r#type: ArgumentType::Required,
};
vec![
icepick_module::help::Operation {
name: "transfer".to_string(),
description: "Transfer SOL from a Keyfork wallet to an external wallet."
.to_string(),
arguments: vec![
Argument {
name: "amount".to_string(),
description: "The amount of SOL to transfer.".to_string(),
r#type: ArgumentType::Required,
},
account.clone(),
Argument {
name: "to-address".to_string(),
description: "The address to send SOL to.".to_string(),
r#type: ArgumentType::Required,
},
from_address.clone(),
blockhash.clone(),
fee.clone(),
fee_payer.clone(),
fee_payer_address.clone(),
],
},
icepick_module::help::Operation {
name: "stake".to_string(),
description: "Stake SOL to earn rewards.".to_string(),
arguments: vec![
Argument {
name: "amount".to_string(),
description: "The amount of SOL to stake.".to_string(),
r#type: ArgumentType::Required,
},
account.clone(),
from_address.clone(),
blockhash.clone(),
fee.clone(),
fee_payer.clone(),
fee_payer_address.clone(),
],
},
icepick_module::help::Operation {
name: "sign".to_string(),
description: "Sign a previously-generated transaction.".to_string(),
arguments: vec![],
},
]
}
fn handle_request(request: Self::Request) -> Result<serde_json::Value, Self::Error> {
match request.operation {
Operation::Transfer(Transfer {
amount,
from_account,
to_address,
from_address,
blockhash,
fee: _,
fee_payer,
fee_payer_address,
}) => {
// TODO:
// parse address for to_address
let amount = f64::from_str(&amount).expect("float amount");
let amount: u64 = (amount * LAMPORTS_PER_SOL as f64) as u64;
use solana_sdk::pubkey::Pubkey;
let to_pk = Pubkey::from_str(&to_address).unwrap();
let from_pk = Pubkey::from_str(&from_address).unwrap();
let payer_account_and_pk = {
// If a fee payer is given, a fee payer address must also be given, since the
// address must be known before signing the transaction.
match (&fee_payer, &fee_payer_address) {
(Some(payer), Some(address)) => {
// Use the provided account
Some((payer.clone(), Pubkey::from_str_const(address)))
}
(None, None) => {
// Use the transaction account
None
}
_ => panic!("Invalid combination of fee_payer and fee_payer_address"),
}
};
let instruction =
solana_sdk::system_instruction::transfer(&from_pk, &to_pk, amount);
let hash = solana_sdk::hash::Hash::from_str(&blockhash).unwrap();
let message = solana_sdk::message::Message::new_with_blockhash(
&[instruction],
payer_account_and_pk.map(|v| v.1).as_ref(),
&hash,
);
let transaction = solana_sdk::transaction::Transaction::new_unsigned(message);
let mut required_derivation_indices = vec![];
// TODO: error handling from_str
let from_account = from_account.and_then(|a| u32::from_str(&a).ok()).unwrap_or(0);
required_derivation_indices.push(from_account);
Ok(serde_json::json!({
"blob": transaction,
}))
}
Operation::Sign(Sign {}) => {
let blob = request.blob.expect("passed in instruction blob");
let transaction: solana_sdk::transaction::Transaction =
serde_json::from_value(blob).expect("valid message blob");
dbg!(transaction);
Ok(serde_json::json!({
"blob": []
}))
}
}
}
}

View File

@ -0,0 +1,8 @@
[package]
name = "icepick-module"
version = "0.1.0"
edition = "2021"
[dependencies]
serde = { workspace = true, features = ["derive"] }
serde_json.workspace = true

View File

@ -0,0 +1,118 @@
/// The types used to build the `help` documentation for a module, as well as determine the
/// arguments that can be automatically detected by Icepick.
pub mod help {
use serde::{Deserialize, Serialize};
/// An operation that can be exposed to a frontend.
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct Operation {
/// The name of the argument, in the format expected by the deserializer.
pub name: String,
/// A description of what the operation does.
pub description: String,
/// The arguments to be provided to the operation's deserializer.
pub arguments: Vec<Argument>,
}
/*
/// The context of whether a signature is signed, needs to be signed, or has been signed.
#[derive(Serialize, Deserialize, Clone)]
pub enum SigningContext {
/// This operation accepts a signed blob.
Signed,
/// This operation accepts an unsigned blob and will return a signed blob.
Signer,
/// This operation will return an unsigned blob.
Unsigned,
}
*/
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum ArgumentType {
Required,
Optional,
}
/// An argument to an operation.
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct Argument {
/// The name of the argument, in the format expected by the deserializer.
pub name: String,
/// A description of the format and parameters of an argument.
pub description: String,
/// The type of argument - this may affect how it displays in the frontend.
pub r#type: ArgumentType,
}
}
/// Implementation methods for Icepick Modules, performed over command I/O using JSON.
pub trait Module {
/// The error type returned by an operation.
type Error: std::error::Error + 'static;
/// The request type. See [`Module::handle_request`] for more information.
type Request: serde::de::DeserializeOwned + std::fmt::Debug + 'static;
/// Describe the operations interface of RPC calls. This information will be used to generate a
/// frontend for users to perform requests.
fn describe_operations() -> Vec<help::Operation>;
/// Handle an incoming request. Requests can often be formed using the following schema:
///
/// ```rust
/// #[derive(serde::Serialize, serde::Deserialize, Debug)]
/// #[serde(tag = "operation", rename_all="kebab-case")]
/// enum Request {
/// Transfer {
/// // This is the amount of the "primary" currency you'd be receiving, such as Ether,
/// // Bitcoin, or Sol.
/// amount: f64,
///
/// // This would be your native wallet address type. In this example, we'll just use a
/// // String.
/// to_address: String,
///
/// // This is the address of the derivation account.
/// from_account: u32,
/// }
///
/// Stake {
/// amount: f64,
/// from_account: u32,
/// }
///
/// Sign {
/// blob:
/// }
/// }
/// ```
fn handle_request(request: Self::Request) -> Result<serde_json::Value, Self::Error>;
fn run_responder() -> Result<(), Box<dyn std::error::Error>> {
let mut lines = std::io::stdin().lines();
while let Some(line) = lines.next().transpose()? {
let value: serde_json::Value = serde_json::from_str(&line)?;
if let Some(serde_json::Value::String(operation)) = value.get("operation") {
if operation == "help" {
println!("{}", serde_json::to_string(&Self::describe_operations())?);
continue;
}
if operation == "exit" {
break;
}
}
// TODO: error handling
let request: Self::Request = serde_json::from_value(value).expect("good value");
let response = Self::handle_request(request)?;
println!("{}", serde_json::to_string(&response).unwrap());
}
Ok(())
}
}

View File

@ -4,4 +4,7 @@ version = "0.1.0"
edition = "2021"
[dependencies]
clap = { version = "4.5.20", features = ["cargo", "derive"] }
clap = { version = "4.5.20", features = ["cargo", "derive", "string"] }
icepick-module = { version = "0.1.0", path = "../icepick-module" }
serde_json.workspace = true
thiserror = "2.0.3"

View File

@ -1,139 +1,114 @@
#![allow(clippy::upper_case_acronyms)]
use clap::command;
use icepick_module::help::*;
use std::{
io::{IsTerminal, Write},
process::{Command, Stdio},
};
use clap::{Arg, Command};
pub fn guessed_bins() -> Vec<String> {
"sol".split_whitespace().map(|s| s.to_owned()).collect()
}
// TODO: extract as icepick_coin
mod icepick_coin {
use clap::Command;
pub trait Coin {
/// Return the name for the coin, in lowercase.
fn coin_name(&self) -> &'static str;
/// Return a description of the coin.
fn coin_description(&self) -> &'static str;
/// Given a Command, add any arguments relevant to the Transfer operation.
///
/// All commands by default have the following values:
/// * `amount`: The amount of the currency to transfer.
/// * `to`: The account to transfer the currency to.
/// * `from_account`: The non-default derivation account to send currency from.
fn build_transfer_command(&self, _command: Command) -> Result<Command, ()> {
Err(())
}
/// Given a Command, add any arguments relevant to the Stake operation.
fn build_stake_command(&self, _command: Command) -> Result<Command, ()> {
Err(())
}
pub fn get_command(bin_name: &str) -> (&str, Vec<&str>) {
if std::env::vars().any(|(k, _)| &k == "ICEPICK_USE_CARGO") {
("cargo", vec!["run", "--bin", bin_name, "--"])
} else {
(bin_name, vec![])
}
}
// TODO: extract as icepick_eth
mod eth {
use super::icepick_coin::Coin;
use clap::arg;
pub struct ETH;
impl Coin for ETH {
fn coin_name(&self) -> &'static str {
"eth"
pub fn do_cli_thing() {
let mut commands = vec![];
let mut icepick_command = command!();
for coin_bin in guessed_bins() {
// try to run the "help" operation on any bins.
let bin = format!("icepick-{coin_bin}");
let (command, args) = get_command(&bin);
let mut child = Command::new(command)
.args(args)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.spawn()
.unwrap();
let mut input = child.stdin.take().unwrap();
input
.write_all("{\"operation\": \"help\"}\n".as_bytes())
.unwrap();
input
.write_all("{\"operation\": \"exit\"}\n".as_bytes())
.unwrap();
let output = child.wait_with_output().unwrap().stdout;
let operations: Vec<Operation> = serde_json::from_slice::<Vec<Operation>>(&output)
.expect("successful deserialization of operation");
commands.push((coin_bin, operations));
}
let commands = commands.leak();
for command in commands.iter() {
let mut subcommand = clap::Command::new(command.0.as_str());
for op in &command.1 {
let mut op_command = clap::Command::new(&op.name).about(&op.description);
for arg in &op.arguments {
let mut op_arg = clap::Arg::new(&arg.name).help(arg.description.as_str());
op_arg = match arg.r#type {
ArgumentType::Required => op_arg.required(true),
ArgumentType::Optional => op_arg.required(false).long(&arg.name),
};
op_command = op_command.arg(op_arg);
}
subcommand = subcommand.subcommand(op_command);
}
icepick_command = icepick_command.subcommand(subcommand);
}
fn coin_description(&self) -> &'static str {
"The leading platform for innovative apps and blockchain networks."
let stdin = std::io::stdin();
let mut cli_input: Option<serde_json::Value> = None;
// HACK: Allow good UX when running from CLI without piping in a blob.
// This is because we have CLI arguments _only_ for the module. We _could_ add a global
// argument `--input` instead of reading input.
if !stdin.is_terminal() {
cli_input = serde_json::from_reader(stdin).ok();
}
dbg!(&cli_input);
let blob = cli_input.as_ref().and_then(|json| {
json.get("blob")
});
fn build_transfer_command(&self, command: clap::Command) -> Result<clap::Command, ()> {
Ok(command
.arg(arg!(--gas <GAS> "Specify custom gas for the transaction."))
.arg(arg!(--"gas-payer" <ACCOUNT> "Use an alternative derivation account for paying gas.")))
let matches = icepick_command.get_matches();
if let Some((module, matches)) = matches.subcommand() {
if let Some((subcommand, matches)) = matches.subcommand() {
if let Some(operation) = commands
.iter()
.find(|(name, _)| name == module)
.and_then(|(_, operations)| operations.iter().find(|o| o.name == subcommand))
{
let mut args = std::collections::HashMap::<&String, Option<&String>>::with_capacity(
operation.arguments.len(),
);
for arg in &operation.arguments {
args.insert(&arg.name, matches.get_one::<String>(&arg.name));
}
let json = serde_json::json!({
"operation": subcommand,
"values": args,
"derived-keys": [],
"blob": blob,
});
let bin = format!("icepick-{module}");
let (command, args) = get_command(&bin);
let mut child = Command::new(command)
.args(args)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.spawn()
.unwrap();
let mut input = child.stdin.take().unwrap();
serde_json::to_writer(&mut input, &json).unwrap();
input.write_all(b"\n{\"operation\": \"exit\"}\n").unwrap();
let output = child.wait_with_output().unwrap().stdout;
let json: serde_json::Value = serde_json::from_slice(&output).expect("valid json");
let json_as_str = serde_json::to_string(&json).unwrap();
println!("{json_as_str}");
}
fn build_stake_command(&self, _command: clap::Command) -> Result<clap::Command, ()> {
Err(())
}
}
}
use icepick_coin::Coin;
fn build_transfer_command() -> Command {
Command::new("transfer")
.about("Transfer coins from one wallet to another.")
}
fn build_stake_command() -> Command {
Command::new("stake")
.about("Stake coins.")
}
fn build_transfer_subcommand(coin: &dyn Coin) -> Command {
let name = coin.coin_name();
let description = coin.coin_description();
let uppercase_name = name.to_uppercase();
Command::new(name)
.about(description)
.arg(Arg::new("amount").help(format!("Amount of {uppercase_name} to transfer")))
.arg(Arg::new("to_address").help(format!("Account to send {uppercase_name} to")))
.arg(
Arg::new("from_account")
.help(format!(
"Specify a non-default derivation account to send {uppercase_name} from"
))
.required(false),
)
}
fn build_stake_subcommand(coin: &dyn Coin) -> Command {
let name = coin.coin_name();
let description = coin.coin_description();
let uppercase_name = name.to_uppercase();
Command::new(name)
.about(description)
.arg(Arg::new("amount").help(format!("Amount of {uppercase_name} to stake")))
.arg(
Arg::new("from_account")
.help(format!(
"Specify a non-default derivation account to send {uppercase_name} from"
))
.required(false),
)
}
pub fn build_command() -> Command {
let command = clap::command!();
let mut transfer_subcommand = build_transfer_command();
let mut stake_subcommand = build_stake_command();
// TODO: this will break. how do i support multiple types here?
#[allow(clippy::single_element_loop)]
for value in &[eth::ETH] {
let res = value.build_transfer_command(build_transfer_subcommand(value));
match res {
Ok(subcommand) => {
transfer_subcommand = transfer_subcommand.subcommand(subcommand);
}
Err(_) => {
// This coin does not support the given operation.
}
}
let res = value.build_stake_command(build_stake_subcommand(value));
match res {
Ok(subcommand) => {
stake_subcommand = stake_subcommand.subcommand(subcommand);
}
Err(_) => {
// This coin does not support the given operation.
}
}
}
command
.subcommand(transfer_subcommand)
.subcommand(stake_subcommand)
}
pub fn handle_matches(_matches: clap::ArgMatches) -> Result<(), Box<dyn std::error::Error>> {
Ok(())
}

View File

@ -1,8 +1,24 @@
use std::process::ExitCode;
mod cli;
fn main() {
let command = cli::build_command();
let matches = command.get_matches();
cli::handle_matches(matches).unwrap();
println!("Hello, world!");
// Yoinked from keyfork-bin
// TODO: make public in keyfork-bin, make a new release
fn report_err<E: std::error::Error>(e: E) {
eprintln!("Unable to run command: {e}");
let mut source = e.source();
while let Some(new_error) = source.take() {
eprintln!("- Caused by: {new_error}");
source = new_error.source();
}
}
fn main() -> ExitCode {
cli::do_cli_thing();
if let Err(e) = Ok::<_, Box<dyn std::error::Error>>(()) {
report_err(&*e);
return ExitCode::FAILURE;
}
ExitCode::SUCCESS
}