diff --git a/@space-operator/contracts/src/generated/flow_server_openapi.ts b/@space-operator/contracts/src/generated/flow_server_openapi.ts index 0d3e5a2b..244fc9f7 100644 --- a/@space-operator/contracts/src/generated/flow_server_openapi.ts +++ b/@space-operator/contracts/src/generated/flow_server_openapi.ts @@ -573,6 +573,7 @@ export interface components { inputs?: { [key: string]: components["schemas"]["FlowInputValueDoc"]; } | null; + output_instructions?: boolean | null; }; StartFlowParamsDoc: { environment?: { diff --git a/crates/cmds-solana/node-definitions/spl_token/transfer_checked.jsonc b/crates/cmds-solana/node-definitions/spl_token/transfer_checked.jsonc index 29dbe2d7..1e084710 100644 --- a/crates/cmds-solana/node-definitions/spl_token/transfer_checked.jsonc +++ b/crates/cmds-solana/node-definitions/spl_token/transfer_checked.jsonc @@ -57,7 +57,6 @@ { "name": "signature", "type": "signature", - "optional": true, "tooltip": "Transaction signature" }, { diff --git a/crates/cmds-solana/src/system_program/transfer_token.rs b/crates/cmds-solana/src/system_program/transfer_token.rs index 04248110..9c4473ae 100644 --- a/crates/cmds-solana/src/system_program/transfer_token.rs +++ b/crates/cmds-solana/src/system_program/transfer_token.rs @@ -49,6 +49,8 @@ pub struct Input { pub struct Output { #[serde(default, with = "value::signature::opt")] pub signature: Option, + #[serde(with = "value::pubkey")] + pub recipient_token_account: Pubkey, } async fn run(mut ctx: CommandContext, input: Input) -> Result { @@ -183,7 +185,10 @@ async fn run(mut ctx: CommandContext, input: Input) -> Result Result< const SWIG_PROGRAM_ID: Pubkey = solana_pubkey::pubkey!("swigypWHEksbC64pWKwah1WTeh9JXwx8H1rJHLdbQMB"); +const COMPUTE_BUDGET_PROGRAM_ID: Pubkey = + solana_pubkey::pubkey!("ComputeBudget111111111111111111111111111111"); +const LIGHTHOUSE_PROGRAM_ID: Pubkey = + solana_pubkey::pubkey!("L2TExMFKdjpN9kozasaurPirfHy9P8sbXoAN1qA3S95"); +const SPL_MEMO_PROGRAM_ID: Pubkey = + solana_pubkey::pubkey!("MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr"); +const SPL_MEMO_LEGACY_PROGRAM_ID: Pubkey = + solana_pubkey::pubkey!("Memo1UhkJRfHyvLMcVucJwxXeuD728EqVDDwQDxFMNo"); /// Check if a message references the Swig program in any of its instructions. fn contains_swig_program(msg: &v0::Message) -> bool { @@ -312,13 +322,232 @@ fn contains_swig_program(msg: &v0::Message) -> bool { }) } +fn instruction_program( + msg: &v0::Message, + ix: &CompiledInstruction, +) -> Result { + msg.account_keys + .get(ix.program_id_index as usize) + .copied() + .ok_or_else(|| anyhow!("instruction program index out of bounds")) +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum AccountIdentity { + Static(Pubkey), + Lookup { table: Pubkey, address_index: u8 }, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct InstructionAccount { + identity: AccountIdentity, + is_signer: bool, + is_writable: bool, +} + +fn account_identity(msg: &v0::Message, index: usize) -> Result { + if let Some(pubkey) = msg.account_keys.get(index) { + return Ok(AccountIdentity::Static(*pubkey)); + } + + let mut offset = index + .checked_sub(msg.account_keys.len()) + .ok_or_else(|| anyhow!("instruction account index out of bounds"))?; + + for lookup in &msg.address_table_lookups { + for address_index in &lookup.writable_indexes { + if offset == 0 { + return Ok(AccountIdentity::Lookup { + table: lookup.account_key, + address_index: *address_index, + }); + } + offset -= 1; + } + } + + for lookup in &msg.address_table_lookups { + for address_index in &lookup.readonly_indexes { + if offset == 0 { + return Ok(AccountIdentity::Lookup { + table: lookup.account_key, + address_index: *address_index, + }); + } + offset -= 1; + } + } + + Err(anyhow!("instruction account index out of bounds")) +} + +fn instruction_accounts( + msg: &v0::Message, + ix: &CompiledInstruction, +) -> Result, anyhow::Error> { + ix.accounts + .iter() + .map(|index| { + let index = *index as usize; + let identity = account_identity(msg, index)?; + let is_signer = index < msg.header.num_required_signatures as usize; + let is_writable = msg.is_maybe_writable(index, None); + Ok(InstructionAccount { + identity, + is_signer, + is_writable, + }) + }) + .collect() +} + +fn is_allowed_compute_budget_instruction( + msg: &v0::Message, + ix: &CompiledInstruction, +) -> Result { + if instruction_program(msg, ix)? != COMPUTE_BUDGET_PROGRAM_ID { + return Ok(false); + } + ensure!( + ix.accounts.is_empty(), + "compute budget instruction must not include accounts" + ); + let Some((&tag, rest)) = ix.data.split_first() else { + return Ok(true); + }; + let allowed = match (tag, rest.len()) { + // RequestUnitsDeprecated { units: u32, additional_fee: u32 } + (0, 8) => { + let units = u32::from_le_bytes(rest[0..4].try_into().unwrap()); + let additional_fee = u32::from_le_bytes(rest[4..8].try_into().unwrap()); + units <= 1_400_000 && additional_fee <= 1_500_000 + } + // RequestHeapFrame(u32) + (1, 4) => { + let heap_bytes = u32::from_le_bytes(rest.try_into().unwrap()); + heap_bytes <= 256 * 1024 + } + // SetComputeUnitLimit(u32) + (2, 4) => { + let units = u32::from_le_bytes(rest.try_into().unwrap()); + units <= 1_400_000 + } + // SetComputeUnitPrice(u64) + (3, 8) => { + let micro_lamports = u64::from_le_bytes(rest.try_into().unwrap()); + micro_lamports <= 1_000_000 + } + // SetLoadedAccountsDataSizeLimit(u32) + (4, 4) => { + let bytes = u32::from_le_bytes(rest.try_into().unwrap()); + bytes <= 1_048_576 + } + _ => rest.len() <= 16, + }; + Ok(allowed) +} + +fn is_allowed_wallet_added_instruction( + original: &v0::Message, + modified: &v0::Message, + ix: &CompiledInstruction, +) -> Result { + if is_allowed_compute_budget_instruction(modified, ix)? { + return Ok(true); + } + + let program = instruction_program(modified, ix)?; + let original_static_accounts = original + .account_keys + .iter() + .copied() + .collect::>(); + let original_signers = original + .account_keys + .iter() + .take(original.header.num_required_signatures as usize) + .copied() + .collect::>(); + let is_safe_wallet_annotation_account = |account: &InstructionAccount| { + let AccountIdentity::Static(pubkey) = account.identity else { + return false; + }; + if account.is_signer { + return original_signers.contains(&pubkey); + } + !original_static_accounts.contains(&pubkey) + }; + + if program == SPL_MEMO_PROGRAM_ID || program == SPL_MEMO_LEGACY_PROGRAM_ID { + let accounts = instruction_accounts(modified, ix)?; + return Ok(ix.data.len() <= 256 + && accounts + .iter() + .all(|account| is_safe_wallet_annotation_account(account))); + } + + if program == LIGHTHOUSE_PROGRAM_ID { + if ix.data.len() > 512 || ix.accounts.len() > 8 { + return Ok(false); + } + let accounts = instruction_accounts(modified, ix)?; + return Ok(accounts.iter().all(|account| { + let AccountIdentity::Static(pubkey) = account.identity else { + return false; + }; + !account.is_signer || original_signers.contains(&pubkey) + })); + } + + Ok(false) +} + +fn business_instructions<'a>( + msg: &'a v0::Message, +) -> Result, anyhow::Error> { + msg.instructions + .iter() + .filter_map(|ix| match is_allowed_compute_budget_instruction(msg, ix) { + Ok(true) => None, + Ok(false) => Some(Ok(ix)), + Err(error) => Some(Err(error)), + }) + .collect() +} + +fn business_instructions_allowing_wallet_additions<'a>( + original: &v0::Message, + modified: &'a v0::Message, +) -> Result, anyhow::Error> { + modified + .instructions + .iter() + .filter_map( + |ix| match is_allowed_wallet_added_instruction(original, modified, ix) { + Ok(true) => None, + Ok(false) => Some(Ok(ix)), + Err(error) => Some(Err(error)), + }, + ) + .collect() +} + +fn instruction_program_labels( + msg: &v0::Message, + instructions: &[&CompiledInstruction], +) -> Result, anyhow::Error> { + instructions + .iter() + .map(|ix| instruction_program(msg, ix).map(|program| program.to_string())) + .collect() +} + /// Validate that a modified message is compatible with the original. /// -/// Always checks fee payer and blockhash. For non-Swig messages, also -/// enforces strict structural checks (instruction count, account count, -/// header fields). Swig SignV2 wrapping restructures the transaction to -/// route inner instructions through the Swig program, so structural -/// changes are expected and allowed. +/// Always checks fee payer and blockhash, then compares business +/// instructions while allowing narrowly-scoped wallet-added instructions. +/// Swig instructions are still compared normally; the presence of Swig must +/// not bypass validation of the compiled instruction data and accounts. /// /// `l` is old, `r` is new. pub fn is_same_message_logic(l: &[u8], r: &[u8]) -> Result { @@ -346,91 +575,59 @@ pub fn is_same_message_logic(l: &[u8], r: &[u8]) -> Result= r.header.num_readonly_signed_accounts, + l.header.num_readonly_signed_accounts == r.header.num_readonly_signed_accounts, "different num_readonly_signed_accounts" ); - if l.header.num_readonly_signed_accounts != r.header.num_readonly_signed_accounts { - tracing::warn!( - "less num_readonly_signed_accounts, old = {}, new = {}", - l.header.num_readonly_signed_accounts, - r.header.num_readonly_signed_accounts + for i in 0..l.header.num_required_signatures as usize { + ensure!( + l.account_keys.get(i) == r.account_keys.get(i), + "different signer account {}", + i ); } - ensure!( - l.header.num_readonly_unsigned_accounts >= r.header.num_readonly_unsigned_accounts, - "different num_readonly_unsigned_accounts" - ); - if l.header.num_readonly_unsigned_accounts != r.header.num_readonly_unsigned_accounts { - tracing::warn!( - "less num_readonly_unsigned_accounts, old = {}, new = {}", - l.header.num_readonly_unsigned_accounts, - r.header.num_readonly_unsigned_accounts + + let l_business = business_instructions(&l)?; + let r_business = business_instructions_allowing_wallet_additions(&l, &r)?; + if l_business.len() != r_business.len() { + let old_programs = instruction_program_labels(&l, &l_business)?.join(","); + let new_programs = instruction_program_labels(&r, &r_business)?.join(","); + bail!( + "different business instructions count, old = {}, new = {}, old_programs = [{}], new_programs = [{}]", + l_business.len(), + r_business.len(), + old_programs, + new_programs, ); } - ensure!( - l.account_keys.len() == r.account_keys.len(), - "different account inputs length, old = {}, new = {}", - l.account_keys.len(), - r.account_keys.len() - ); - ensure!( - l.instructions.len() == r.instructions.len(), - "different instructions count, old = {}, new = {}", - l.instructions.len(), - r.instructions.len() - ); - /* - * TODO - for i in 0..l.instructions.len() { - let program_id_l = l - .program_id(i) - .ok_or_else(|| anyhow!("no program id for instruction {}", i))?; - - let program_id_r = r - .program_id(i) - .ok_or_else(|| anyhow!("no program id for instruction {}", i))?; + for (i, (il, ir)) in l_business.iter().zip(r_business.iter()).enumerate() { ensure!( - program_id_l == program_id_r, + instruction_program(&l, il)? == instruction_program(&r, ir)?, "different program id for instruction {}", i ); - let il = &l.instructions[i]; - let ir = &r.instructions[i]; ensure!(il.data == ir.data, "different instruction data {}", i); - let inputs_l = il.accounts.iter().map(|i| l.account_keys.get(*i as usize)); - let inputs_r = ir.accounts.iter().map(|i| r.account_keys.get(*i as usize)); - inputs_l - .zip(inputs_r) - .map(|(l, r)| { - (l == r) - .then_some(()) - .ok_or_else(|| anyhow!("different account inputs for instruction {}", i)) - }) - .collect::, _>>()?; + ensure!( + instruction_accounts(&l, il)? == instruction_accounts(&r, ir)?, + "different account inputs for instruction {}", + i + ); } - */ Ok(r) } @@ -474,3 +671,354 @@ pub fn parse_action_memo(reference: &str) -> Result { run_id, }) } + +#[cfg(test)] +mod tests { + use super::*; + use solana_message::MessageHeader; + + fn serialize_message(message: v0::Message) -> Vec { + bincode1::serialize(&VersionedMessage::V0(message)).unwrap() + } + + fn fee_payer() -> Pubkey { + solana_pubkey::pubkey!("11111111111111111111111111111112") + } + + fn program() -> Pubkey { + solana_pubkey::pubkey!("11111111111111111111111111111113") + } + + fn lookup_table() -> Pubkey { + solana_pubkey::pubkey!("11111111111111111111111111111114") + } + + #[test] + fn same_message_logic_rejects_swig_data_rewrite() { + let original = v0::Message { + header: MessageHeader { + num_required_signatures: 1, + num_readonly_signed_accounts: 0, + num_readonly_unsigned_accounts: 1, + }, + account_keys: vec![fee_payer(), SWIG_PROGRAM_ID], + recent_blockhash: Default::default(), + instructions: vec![CompiledInstruction { + program_id_index: 1, + accounts: vec![], + data: vec![1], + }], + address_table_lookups: vec![], + }; + let mut modified = original.clone(); + modified.instructions[0].data = vec![2]; + + let error = + is_same_message_logic(&serialize_message(original), &serialize_message(modified)) + .unwrap_err() + .to_string(); + assert!(error.contains("different instruction data")); + } + + #[test] + fn same_message_logic_compares_lookup_accounts_after_static_account_insert() { + let lookup = v0::MessageAddressTableLookup { + account_key: lookup_table(), + writable_indexes: vec![7], + readonly_indexes: vec![], + }; + let original = v0::Message { + header: MessageHeader { + num_required_signatures: 1, + num_readonly_signed_accounts: 0, + num_readonly_unsigned_accounts: 1, + }, + account_keys: vec![fee_payer(), program()], + recent_blockhash: Default::default(), + instructions: vec![CompiledInstruction { + program_id_index: 1, + accounts: vec![2], + data: vec![1], + }], + address_table_lookups: vec![lookup.clone()], + }; + let modified = v0::Message { + header: MessageHeader { + num_required_signatures: 1, + num_readonly_signed_accounts: 0, + num_readonly_unsigned_accounts: 2, + }, + account_keys: vec![ + fee_payer(), + program(), + solana_pubkey::pubkey!("11111111111111111111111111111115"), + ], + recent_blockhash: Default::default(), + instructions: vec![CompiledInstruction { + program_id_index: 1, + accounts: vec![3], + data: vec![1], + }], + address_table_lookups: vec![lookup], + }; + + is_same_message_logic(&serialize_message(original), &serialize_message(modified)).unwrap(); + } + + #[test] + fn same_message_logic_rejects_different_lookup_account() { + let lookup = v0::MessageAddressTableLookup { + account_key: lookup_table(), + writable_indexes: vec![7, 8], + readonly_indexes: vec![], + }; + let original = v0::Message { + header: MessageHeader { + num_required_signatures: 1, + num_readonly_signed_accounts: 0, + num_readonly_unsigned_accounts: 1, + }, + account_keys: vec![fee_payer(), program()], + recent_blockhash: Default::default(), + instructions: vec![CompiledInstruction { + program_id_index: 1, + accounts: vec![2], + data: vec![1], + }], + address_table_lookups: vec![lookup.clone()], + }; + let mut modified = original.clone(); + modified.instructions[0].accounts = vec![3]; + + let error = + is_same_message_logic(&serialize_message(original), &serialize_message(modified)) + .unwrap_err() + .to_string(); + assert!(error.contains("different account inputs")); + } + + #[test] + fn same_message_logic_allows_wallet_added_lighthouse_signer_annotation() { + let original = v0::Message { + header: MessageHeader { + num_required_signatures: 1, + num_readonly_signed_accounts: 0, + num_readonly_unsigned_accounts: 1, + }, + account_keys: vec![fee_payer(), program()], + recent_blockhash: Default::default(), + instructions: vec![CompiledInstruction { + program_id_index: 1, + accounts: vec![], + data: vec![1], + }], + address_table_lookups: vec![], + }; + let modified = v0::Message { + header: MessageHeader { + num_required_signatures: 1, + num_readonly_signed_accounts: 0, + num_readonly_unsigned_accounts: 2, + }, + account_keys: vec![fee_payer(), program(), LIGHTHOUSE_PROGRAM_ID], + recent_blockhash: Default::default(), + instructions: vec![ + CompiledInstruction { + program_id_index: 1, + accounts: vec![], + data: vec![1], + }, + CompiledInstruction { + program_id_index: 2, + accounts: vec![0], + data: vec![1], + }, + ], + address_table_lookups: vec![], + }; + + is_same_message_logic(&serialize_message(original), &serialize_message(modified)).unwrap(); + } + + #[test] + fn same_message_logic_allows_wallet_added_lighthouse_original_account_reference() { + let original = v0::Message { + header: MessageHeader { + num_required_signatures: 1, + num_readonly_signed_accounts: 0, + num_readonly_unsigned_accounts: 1, + }, + account_keys: vec![fee_payer(), lookup_table(), program()], + recent_blockhash: Default::default(), + instructions: vec![CompiledInstruction { + program_id_index: 2, + accounts: vec![1], + data: vec![1], + }], + address_table_lookups: vec![], + }; + let modified = v0::Message { + header: MessageHeader { + num_required_signatures: 1, + num_readonly_signed_accounts: 0, + num_readonly_unsigned_accounts: 2, + }, + account_keys: vec![ + fee_payer(), + lookup_table(), + program(), + LIGHTHOUSE_PROGRAM_ID, + ], + recent_blockhash: Default::default(), + instructions: vec![ + CompiledInstruction { + program_id_index: 2, + accounts: vec![1], + data: vec![1], + }, + CompiledInstruction { + program_id_index: 3, + accounts: vec![1], + data: vec![1], + }, + ], + address_table_lookups: vec![], + }; + + is_same_message_logic(&serialize_message(original), &serialize_message(modified)).unwrap(); + } + + #[test] + fn same_message_logic_rejects_wallet_added_lighthouse_extra_signer() { + let extra_signer = solana_pubkey::pubkey!("11111111111111111111111111111116"); + let original = v0::Message { + header: MessageHeader { + num_required_signatures: 1, + num_readonly_signed_accounts: 0, + num_readonly_unsigned_accounts: 1, + }, + account_keys: vec![fee_payer(), program()], + recent_blockhash: Default::default(), + instructions: vec![CompiledInstruction { + program_id_index: 1, + accounts: vec![], + data: vec![1], + }], + address_table_lookups: vec![], + }; + let modified = v0::Message { + header: MessageHeader { + num_required_signatures: 2, + num_readonly_signed_accounts: 0, + num_readonly_unsigned_accounts: 2, + }, + account_keys: vec![fee_payer(), extra_signer, program(), LIGHTHOUSE_PROGRAM_ID], + recent_blockhash: Default::default(), + instructions: vec![ + CompiledInstruction { + program_id_index: 2, + accounts: vec![], + data: vec![1], + }, + CompiledInstruction { + program_id_index: 3, + accounts: vec![1], + data: vec![1], + }, + ], + address_table_lookups: vec![], + }; + + let error = + is_same_message_logic(&serialize_message(original), &serialize_message(modified)) + .unwrap_err() + .to_string(); + assert!(error.contains("different num_required_signatures")); + } + + #[test] + fn same_message_logic_allows_wallet_added_unknown_compute_budget_instruction() { + let original = v0::Message { + header: MessageHeader { + num_required_signatures: 1, + num_readonly_signed_accounts: 0, + num_readonly_unsigned_accounts: 1, + }, + account_keys: vec![fee_payer(), program()], + recent_blockhash: Default::default(), + instructions: vec![CompiledInstruction { + program_id_index: 1, + accounts: vec![], + data: vec![1], + }], + address_table_lookups: vec![], + }; + let modified = v0::Message { + header: MessageHeader { + num_required_signatures: 1, + num_readonly_signed_accounts: 0, + num_readonly_unsigned_accounts: 2, + }, + account_keys: vec![fee_payer(), program(), COMPUTE_BUDGET_PROGRAM_ID], + recent_blockhash: Default::default(), + instructions: vec![ + CompiledInstruction { + program_id_index: 2, + accounts: vec![], + data: vec![9, 1, 2, 3, 4], + }, + CompiledInstruction { + program_id_index: 1, + accounts: vec![], + data: vec![1], + }, + ], + address_table_lookups: vec![], + }; + + is_same_message_logic(&serialize_message(original), &serialize_message(modified)).unwrap(); + } + + #[test] + fn same_message_logic_allows_wallet_added_memo_signer_annotation() { + let original = v0::Message { + header: MessageHeader { + num_required_signatures: 1, + num_readonly_signed_accounts: 0, + num_readonly_unsigned_accounts: 1, + }, + account_keys: vec![fee_payer(), program()], + recent_blockhash: Default::default(), + instructions: vec![CompiledInstruction { + program_id_index: 1, + accounts: vec![], + data: vec![1], + }], + address_table_lookups: vec![], + }; + let modified = v0::Message { + header: MessageHeader { + num_required_signatures: 1, + num_readonly_signed_accounts: 0, + num_readonly_unsigned_accounts: 2, + }, + account_keys: vec![fee_payer(), program(), SPL_MEMO_PROGRAM_ID], + recent_blockhash: Default::default(), + instructions: vec![ + CompiledInstruction { + program_id_index: 1, + accounts: vec![], + data: vec![1], + }, + CompiledInstruction { + program_id_index: 2, + accounts: vec![0], + data: b"wallet memo".to_vec(), + }, + ], + address_table_lookups: vec![], + }; + + is_same_message_logic(&serialize_message(original), &serialize_message(modified)).unwrap(); + } +} diff --git a/crates/flow-server/src/api/start_deployment.rs b/crates/flow-server/src/api/start_deployment.rs index ec3dbaff..a3cb6e56 100644 --- a/crates/flow-server/src/api/start_deployment.rs +++ b/crates/flow-server/src/api/start_deployment.rs @@ -79,6 +79,8 @@ pub struct Params { inputs: Option, #[serde_as(as = "Option")] action_signer: Option, + #[serde(default)] + output_instructions: bool, } #[derive(Serialize)] @@ -148,9 +150,13 @@ async fn start_deployment( // tracing::debug!("{}", pretty_print(req.headers())); let params = optional(params)?.map(|x| x.0); - let (action_signer, inputs) = match params { - Some(params) => (params.action_signer, params.inputs.unwrap_or_default()), - None => (None, Default::default()), + let (action_signer, inputs, output_instructions) = match params { + Some(params) => ( + params.action_signer, + params.inputs.unwrap_or_default(), + params.output_instructions, + ), + None => (None, Default::default(), false), }; let preserved_bearer_token = match &user { AuthEither::One(user) => { @@ -183,6 +189,9 @@ async fn start_deployment( Query::Id { id } => id, }; let mut deployment = conn.get_deployment(&id).await?; + if output_instructions { + deployment.output_instructions = true; + } let conn = db.get_user_conn(deployment.user_id).await?; deployment.flows = conn.get_deployment_flows(&id).await?; diff --git a/crates/flow-server/src/openapi.rs b/crates/flow-server/src/openapi.rs index c2bcb565..15cbe11a 100644 --- a/crates/flow-server/src/openapi.rs +++ b/crates/flow-server/src/openapi.rs @@ -194,6 +194,7 @@ struct CloneFlowOutputDoc { struct StartDeploymentParamsDoc { inputs: Option>, action_signer: Option, + output_instructions: Option, } #[derive(Serialize, Deserialize, ToSchema)] diff --git a/crates/flow/src/flow_graph.rs b/crates/flow/src/flow_graph.rs index e2bfcfcd..4f433fc8 100644 --- a/crates/flow/src/flow_graph.rs +++ b/crates/flow/src/flow_graph.rs @@ -1227,7 +1227,7 @@ impl FlowGraph { { return; } - let err_str = error.to_string(); + let err_str = format!("{error:#}"); o.resp.send(Err(execute::Error::from_anyhow(error))).ok(); node_error(&s.event_tx, &mut s.result, info.id, info.times, err_str); } diff --git a/lib/flow-lib/src/config/mod.rs b/lib/flow-lib/src/config/mod.rs index a767007d..13b011be 100644 --- a/lib/flow-lib/src/config/mod.rs +++ b/lib/flow-lib/src/config/mod.rs @@ -205,8 +205,13 @@ pub struct SolanaClientConfig { impl SolanaClientConfig { pub fn build_client(&self, http: Option) -> RpcClient { + let url = if self.url.trim().is_empty() { + self.cluster.url() + } else { + self.url.clone() + }; RpcClient::new_sender( - HttpSender::new_with_client(self.url.clone(), http.unwrap_or_default()), + HttpSender::new_with_client(url, http.unwrap_or_default()), RpcClientConfig { commitment_config: CommitmentConfig::finalized(), confirm_transaction_initial_timeout: Some(Duration::from_secs(180)), diff --git a/schema/flow-server.openapi.json b/schema/flow-server.openapi.json index 22543ab8..7859ab99 100644 --- a/schema/flow-server.openapi.json +++ b/schema/flow-server.openapi.json @@ -1830,6 +1830,12 @@ "propertyNames": { "type": "string" } + }, + "output_instructions": { + "type": [ + "boolean", + "null" + ] } } },