icepick-solana: add support for utilizing durable nonces

This commit is contained in:
Ryan Heywood 2024-12-27 02:21:09 -05:00
parent be5f9a9fa0
commit ca0fc3eef9
Signed by: ryan
GPG Key ID: 8E401478A3FBEF72
2 changed files with 331 additions and 224 deletions

View File

@ -12,6 +12,7 @@ icepick-module = { version = "0.1.0", path = "../../icepick-module" }
serde = { workspace = true, features = ["derive"] }
serde_json.workspace = true
solana-rpc-client = { version = "2.1.1", default-features = false }
solana-rpc-client-nonce-utils = "2.1.7"
solana-sdk = { version = "2.1.1" }
solana-transaction-status = "2.1.1"
solana-transaction-status-client-types = "2.1.1"

View File

@ -129,6 +129,21 @@ impl std::fmt::Display for Cluster {
}
}
// NOTE: While, technically, they both fit in the same width, it is _important_ to have different
// functionality based on which is provided, as Nonce requires an incremention instruction.
#[derive(Serialize, Deserialize, Debug)]
#[serde(rename_all = "snake_case", untagged)]
pub enum Hashable {
Nonce {
nonce_data: String,
nonce_address: String,
nonce_authority: String,
},
Blockhash {
blockhash: String,
},
}
#[derive(Serialize, Deserialize, Debug)]
pub struct GetBlockhash {
cluster: Option<Cluster>,
@ -161,6 +176,12 @@ pub struct CreateNonceAccountAndSigningKey {
from_address: String,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct GetNonceAccountData {
nonce_address: String,
cluster: Option<Cluster>,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct FindNonceAccounts {
authorization_address: String,
@ -198,6 +219,19 @@ pub struct TransferToken {
fee_payer_address: Option<String>,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct Compile {
#[serde(flatten)]
hashable: Hashable,
derivation_accounts: Vec<u32>,
instructions: Vec<solana_sdk::instruction::Instruction>,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct Inspect {
transaction: solana_sdk::transaction::Transaction,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct Sign {
blockhash: String,
@ -231,10 +265,13 @@ pub enum Operation {
AwaitFunds(AwaitFunds),
GetTokenInfo(GetTokenInfo),
CreateNonceAccountAndSigningKey(CreateNonceAccountAndSigningKey),
GetNonceAccountData(GetNonceAccountData),
FindNonceAccounts(FindNonceAccounts),
Transfer(Transfer),
CreateTokenAccount(CreateTokenAccount),
TransferToken(TransferToken),
Compile(Compile),
Inspect(Inspect),
Sign(Sign),
Broadcast(Broadcast),
}
@ -292,135 +329,148 @@ impl Module for Solana {
.to_string(),
r#type: ArgumentType::Required,
};
vec![
icepick_module::help::Operation {
name: "get-blockhash".to_string(),
description: "Get the latest blockhash".to_string(),
arguments: vec![cluster.clone()],
},
icepick_module::help::Operation {
name: "generate-wallet".to_string(),
description: "Generate the derivation index for a wallet.".to_string(),
arguments: vec![Argument {
name: "account".to_string(),
description: "The derivation account used for generating the wallet."
.to_string(),
r#type: ArgumentType::Optional,
}],
},
icepick_module::help::Operation {
name: "get-wallet-address".to_string(),
description: "Get the address for a given wallet.".to_string(),
arguments: vec![],
},
icepick_module::help::Operation {
name: "await-funds".to_string(),
description: "Await a minimum amount of funds in an account".to_string(),
arguments: vec![Argument {
let get_blockhash = icepick_module::help::Operation {
name: "get-blockhash".to_string(),
description: "Get the latest blockhash".to_string(),
arguments: vec![cluster.clone()],
};
let generate_wallet = icepick_module::help::Operation {
name: "generate-wallet".to_string(),
description: "Generate the derivation index for a wallet.".to_string(),
arguments: vec![Argument {
name: "account".to_string(),
description: "The derivation account used for generating the wallet.".to_string(),
r#type: ArgumentType::Optional,
}],
};
let get_wallet_address = icepick_module::help::Operation {
name: "get-wallet-address".to_string(),
description: "Get the address for a given wallet.".to_string(),
arguments: vec![],
};
let await_funds = icepick_module::help::Operation {
name: "await-funds".to_string(),
description: "Await a minimum amount of funds in an account".to_string(),
arguments: vec![
Argument {
name: "address".to_string(),
description: "The address to monitor".to_string(),
r#type: ArgumentType::Required,
}, Argument {
},
Argument {
name: "amount".to_string(),
description: "The amount of lamports to await".to_string(),
r#type: ArgumentType::Required,
}],
},
icepick_module::help::Operation {
name: "get-token-info".to_string(),
description: "Get the address for a given token.".to_string(),
arguments: vec![Argument {
name: "token".to_string(),
description: "The token to look up".to_string(),
},
],
};
let get_token_info = icepick_module::help::Operation {
name: "get-token-info".to_string(),
description: "Get the address for a given token.".to_string(),
arguments: vec![Argument {
name: "token".to_string(),
description: "The token to look up".to_string(),
r#type: ArgumentType::Required,
}],
};
let create_nonce_account_and_signing_key = icepick_module::help::Operation {
name: "create-nonce-account-and-signing-key".to_string(),
description: "Create a nonce account for signing durable transactions".to_string(),
arguments: vec![
account.clone(),
from_address.clone(),
Argument {
name: "authorization_address".to_string(),
description: "The account authorized to use and advance the nonce.".to_string(),
r#type: ArgumentType::Required,
}],
},
icepick_module::help::Operation {
name: "create-nonce-account-and-signing-key".to_string(),
description: "Create a nonce account for signing durable transactions".to_string(),
arguments: vec![
account.clone(),
from_address.clone(),
Argument {
name: "authorization_address".to_string(),
description: "The account authorized to use and advance the nonce.".to_string(),
r#type: ArgumentType::Required,
},
],
},
icepick_module::help::Operation {
name: "find-nonce-accounts".to_string(),
description: "Find all nonce accounts for an authorized address".to_string(),
arguments: vec![
cluster.clone(),
Argument {
name: "authorization_address".to_string(),
description: "The account authorized to use and advance nonces.".to_string(),
r#type: ArgumentType::Required,
},
],
},
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(),
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(),
fee.clone(),
fee_payer.clone(),
fee_payer_address.clone(),
],
},
// kinda BS that you have to make an account for a token, but ok.
icepick_module::help::Operation {
name: "create-token-account".to_string(),
description: "Create an account for a given token".to_string(),
arguments: vec![
Argument {
name: "wallet_address".to_string(),
description: "The address of the token.".to_string(),
r#type: ArgumentType::Required,
},
Argument {
name: "token_address".to_string(),
description: "The address of the token.".to_string(),
r#type: ArgumentType::Required,
},
Argument {
name: "funder_address".to_string(),
description: "The address of the funder (signer).".to_string(),
r#type: ArgumentType::Optional,
},
],
},
icepick_module::help::Operation {
},
],
};
let get_nonce_account_data = icepick_module::help::Operation {
name: "get-nonce-account-data".to_string(),
description: "Get the data for a nonce account".to_string(),
arguments: vec![
cluster.clone(),
Argument {
name: "nonce_address".to_string(),
description: "The address of the nonce account.".to_string(),
r#type: ArgumentType::Required,
},
],
};
let find_nonce_accounts = icepick_module::help::Operation {
name: "find-nonce-accounts".to_string(),
description: "Find all nonce accounts for an authorized address".to_string(),
arguments: vec![
cluster.clone(),
Argument {
name: "authorization_address".to_string(),
description: "The account authorized to use and advance nonces.".to_string(),
r#type: ArgumentType::Required,
},
],
};
let transfer = 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(),
fee.clone(),
fee_payer.clone(),
fee_payer_address.clone(),
],
};
let stake = 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(),
fee.clone(),
fee_payer.clone(),
fee_payer_address.clone(),
],
};
// kinda BS that you have to make an account for a token, but ok.
let create_token_account = icepick_module::help::Operation {
name: "create-token-account".to_string(),
description: "Create an account for a given token".to_string(),
arguments: vec![
Argument {
name: "wallet_address".to_string(),
description: "The address of the token.".to_string(),
r#type: ArgumentType::Required,
},
Argument {
name: "token_address".to_string(),
description: "The address of the token.".to_string(),
r#type: ArgumentType::Required,
},
Argument {
name: "funder_address".to_string(),
description: "The address of the funder (signer).".to_string(),
r#type: ArgumentType::Optional,
},
],
};
let transfer_token = icepick_module::help::Operation {
name: "transfer-token".to_string(),
description: "Transfer tokens from a Keyfork wallet to an external wallet."
.to_string(),
@ -455,21 +505,61 @@ impl Module for Solana {
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![Argument {
name: "blockhash".to_string(),
description: "A recent blockhash".to_string(),
r#type: ArgumentType::Required,
}],
},
icepick_module::help::Operation {
name: "broadcast".to_string(),
description: "Broadcast a signed transaction".to_string(),
arguments: vec![cluster.clone()],
},
};
let compile = icepick_module::help::Operation {
name: "compile".to_string(),
description: "Compile instructions into a transaction".to_string(),
arguments: vec![
Argument {
name: "blockhash".to_string(),
description: "A recent blockhash, must be provided in place of nonce"
.to_string(),
r#type: ArgumentType::Optional,
},
Argument {
name: "nonce".to_string(),
description: "A durable nonce, must be provided in place of blockhash"
.to_string(),
r#type: ArgumentType::Optional,
},
],
};
let inspect = icepick_module::help::Operation {
name: "inspect".to_string(),
description: "Print a transaction using base64.".to_string(),
arguments: vec![],
};
let sign = icepick_module::help::Operation {
name: "sign".to_string(),
description: "Sign a previously-generated transaction.".to_string(),
arguments: vec![Argument {
name: "blockhash".to_string(),
description: "A recent blockhash".to_string(),
r#type: ArgumentType::Required,
}],
};
let broadcast = icepick_module::help::Operation {
name: "broadcast".to_string(),
description: "Broadcast a signed transaction".to_string(),
arguments: vec![cluster.clone()],
};
vec![
get_blockhash,
generate_wallet,
get_wallet_address,
await_funds,
get_token_info,
create_nonce_account_and_signing_key,
get_nonce_account_data,
find_nonce_accounts,
transfer,
stake,
create_token_account,
transfer_token,
compile,
inspect,
sign,
broadcast,
]
}
@ -594,6 +684,10 @@ impl Module for Solana {
let from_pk = Pubkey::from_str(&from_address).unwrap();
let authorization_pk = Pubkey::from_str(&authorization_address).unwrap();
if from_account.is_some() {
unimplemented!("alternative derivation accounts are not yet implemented");
}
let instructions = system_instruction::create_nonce_account(
&from_pk,
&keypair.pubkey(),
@ -602,20 +696,35 @@ impl Module for Solana {
1500000,
);
let message = solana_sdk::message::Message::new(&instructions, None);
let transaction = solana_sdk::transaction::Transaction::new_unsigned(message);
let from_account = from_account
.and_then(|a| u32::from_str(&a).ok())
.unwrap_or(0);
let requested_accounts = vec![from_account | 1 << 31];
#[allow(clippy::identity_op)]
Ok(serde_json::json!({
"blob": {
"nonce_pubkey": keypair.pubkey().to_string(),
"nonce_privkey": [keypair.secret().to_bytes()],
"transaction": transaction,
"transaction": instructions,
},
"derivation_accounts": [0u32 | 1 << 31],
}))
}
Operation::GetNonceAccountData(GetNonceAccountData {
nonce_address,
cluster,
}) => {
let nonce_pk = Pubkey::from_str(&nonce_address).unwrap();
let cluster = cluster.unwrap_or(Cluster::MainnetBeta);
let cluster_url = format!("https://api.{cluster}.solana.com");
let client = solana_rpc_client::rpc_client::RpcClient::new(cluster_url);
let nonce_account = client.get_account(&nonce_pk).unwrap();
let nonce =
solana_rpc_client_nonce_utils::data_from_account(&nonce_account).unwrap();
Ok(serde_json::json!({
"blob": {
"authority": nonce.authority.to_string(),
"durable_nonce": nonce.durable_nonce.as_hash().to_string(),
"lamports_per_signature": nonce.fee_calculator.lamports_per_signature,
},
"derivation_accounts": requested_accounts,
}))
}
Operation::FindNonceAccounts(FindNonceAccounts {
@ -749,56 +858,30 @@ impl Module for Solana {
from_account,
to_address,
from_address,
fee: _,
fee,
fee_payer,
fee_payer_address,
}) => {
// TODO:
// parse address for to_address
if from_account.is_some() {
unimplemented!("from_account");
}
if fee.is_some() | fee_payer.is_some() | fee_payer_address.is_some() {
unimplemented!("fee")
}
let amount = f64::from_str(&amount).expect("float amount");
let amount: u64 = (amount * LAMPORTS_PER_SOL as f64) as u64;
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((
u32::from_str(payer).unwrap(),
Pubkey::from_str(address).unwrap(),
))
}
(None, None) => {
// Use the transaction account
None
}
_ => panic!("Invalid combination of fee_payer and fee_payer_address"),
}
};
let instruction = system_instruction::transfer(&from_pk, &to_pk, amount);
let message = solana_sdk::message::Message::new(
&[instruction],
payer_account_and_pk.map(|v| v.1).as_ref(),
);
let transaction = solana_sdk::transaction::Transaction::new_unsigned(message);
// TODO: error handling from_str
let from_account = from_account
.and_then(|a| u32::from_str(&a).ok())
.unwrap_or(0);
let mut requested_accounts = vec![];
requested_accounts.push(from_account | 1 << 31);
if let Some((account, _)) = &payer_account_and_pk {
requested_accounts.push(*account | 1 << 31);
}
#[allow(clippy::identity_op)]
Ok(serde_json::json!({
"blob": {
"transaction": transaction,
"instructions": [instruction],
// This is done in blob since it's compiled in the next step
"derivation_accounts": [0u32 | 1 << 31],
},
"derivation_accounts": requested_accounts,
}))
}
Operation::CreateTokenAccount(CreateTokenAccount {
@ -843,39 +926,23 @@ impl Module for Solana {
fee_payer,
fee_payer_address,
}) => {
// TODO: deduplicate code used in Transfer
if from_account.is_some() {
unimplemented!("from_account");
}
if fee.is_some() | fee_payer.is_some() | fee_payer_address.is_some() {
unimplemented!("fee")
}
let amount = f64::from_str(&amount).expect("float amount");
let decimals = u8::from_str(&decimals).expect("decimals");
let amount: u64 = (amount * 10u64.pow(decimals as u32) as f64) as u64;
use solana_sdk::pubkey::Pubkey;
use spl_associated_token_account::get_associated_token_address;
let to_pk = Pubkey::from_str(&to_address).unwrap();
let from_pk = Pubkey::from_str(&from_address).unwrap();
let token_pk = Pubkey::from_str(&token_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((
u32::from_str(payer).unwrap(),
Pubkey::from_str(address).unwrap(),
))
}
(None, None) => {
// Use the transaction account
None
}
_ => panic!("Invalid combination of fee_payer and fee_payer_address"),
}
};
let token_program_id = spl_token::ID;
let mut signers = vec![&from_pk];
if let Some((_, pk)) = payer_account_and_pk.as_ref() {
signers.push(pk);
}
let from_token_address = get_associated_token_address(&from_pk, &token_pk);
let to_token_address = get_associated_token_address(&to_pk, &token_pk);
@ -892,36 +959,75 @@ impl Module for Solana {
decimals, // decimals
)
.unwrap();
// TODO: check if this works with payer
// TODO: check if this works with multisig
// this is required because the Solana SDK does not set the primary transactional
// key as writable (the one that would be paying computation fees) in the event a
// payer is not provided. The transactional account must be writable for the
// computation fee to be paid.
if payer_account_and_pk.is_none() {
for account in instruction.accounts.iter_mut() {
if account.pubkey == from_pk {
account.is_writable = true;
}
for account in instruction.accounts.iter_mut() {
if account.pubkey == from_pk {
account.is_writable = true;
}
}
let message = solana_sdk::message::Message::new(
&[instruction],
payer_account_and_pk.map(|v| v.1).as_ref(),
);
// message.header.num_readonly_signed_accounts = 0;
let transaction =
solana_sdk::transaction::Transaction::new_unsigned(message.clone());
/*
use base64::prelude::*;
eprintln!("{}", BASE64_STANDARD.encode(transaction.message_data()));
*/
#[allow(clippy::identity_op)]
Ok(serde_json::json!({
"blob": {
"instructions": [instruction],
"derivation_accounts": [0u32 | 1 << 31],
},
}))
}
Operation::Compile(Compile {
hashable,
derivation_accounts,
mut instructions,
}) => {
use solana_sdk::{hash::Hash, message::Message, transaction::Transaction};
let (hash, transaction) = match hashable {
// We already have the account from GetNonceAccountData,
// which also gives us the authority and the nonce itself.
Hashable::Nonce {
nonce_data,
nonce_address,
nonce_authority,
} => {
let account_pk = Pubkey::from_str(&nonce_address).unwrap();
let authority_pk = Pubkey::from_str(&nonce_authority).unwrap();
let hash = Hash::from_str(&nonce_data).unwrap();
let increment_nonce =
system_instruction::advance_nonce_account(&account_pk, &authority_pk);
instructions.insert(0, increment_nonce);
let message = Message::new(&instructions, None);
let transaction = Transaction::new_unsigned(message);
(hash, transaction)
}
Hashable::Blockhash { blockhash } => {
let blockhash = Hash::from_str(&blockhash).unwrap();
let message = Message::new(&instructions, None);
let transaction = Transaction::new_unsigned(message);
(blockhash, transaction)
}
};
Ok(serde_json::json!({
"blob": {
"hash": hash,
"transaction": transaction,
},
"derivation_accounts": [0u32 | 1 << 31],
"derivation_accounts": derivation_accounts,
}))
}
Operation::Inspect(Inspect { transaction }) => {
use base64::prelude::*;
Ok(serde_json::json!({
"blob": {
"formatted_transaction": BASE64_STANDARD.encode(transaction.message_data())
}
}))
}
Operation::Sign(Sign {