-
Notifications
You must be signed in to change notification settings - Fork 166
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Add send transaction
to TS and Rust code examples
#589
Changes from 7 commits
1734e7c
c464ddf
428c216
59d27ec
b7e4c85
0941bd6
329c396
b39eeb9
3e28c5f
d4342f5
535501c
05e32c8
1cc403c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,372 @@ | ||
--- | ||
sidebar_label: Send Transactions | ||
--- | ||
import Tabs from '@theme/Tabs'; | ||
import TabItem from '@theme/TabItem'; | ||
|
||
# Sending and Landing Transactions | ||
In this guide, we'll explore how to use the Whirlpools SDK in conjunction with the Solana SDK to send and successfully land transactions on the Solana blockchain. We'll cover key features such as: | ||
|
||
- Client-side retry | ||
- Prioritization fees | ||
- Compute budget estimation | ||
|
||
For this example, we'll demonstrate how to open a position with liquidity on a Whirlpool. However, you can use the same approach to send transactions for other purposes as well, such as swaps, adding/removing liquidity, and more. | ||
|
||
Make sure you check out [this doc](./02-Environment%20Setup.mdx) to set up your environment. | ||
|
||
## Code Overview | ||
### Dependencies | ||
Let's start by importing the necessary dependencies. | ||
|
||
<Tabs groupId="programming-languages"> | ||
<TabItem value="ts" label="Typescript" default> | ||
```json title="package.json" | ||
"dependencies": { | ||
"@orca-so/whirlpools": "^1.0.0", | ||
"@solana-program/compute-budget": "^0.6.1", | ||
"@solana/web3.js": "^2.0.0", | ||
"tsx": "^4.19.2", | ||
"typescript": "^5.7.2" | ||
}, | ||
``` | ||
```tsx title="sendTransaction.ts" | ||
import { openPositionInstructions, setWhirlpoolsConfig } from '@orca-so/whirlpools'; | ||
import { createSolanaRpc, address, pipe, createTransactionMessage, setTransactionMessageFeePayer, setTransactionMessageLifetimeUsingBlockhash, appendTransactionMessageInstructions, getComputeUnitEstimateForTransactionMessageFactory, prependTransactionMessageInstructions, signTransactionMessageWithSigners, getBase64EncodedWireTransaction, setTransactionMessageFeePayerSigner } from '@solana/web3.js'; | ||
import { getSetComputeUnitLimitInstruction, getSetComputeUnitPriceInstruction } from '@solana-program/compute-budget'; | ||
``` | ||
</TabItem> | ||
<TabItem value="rust" label="Rust"> | ||
```toml title="Cargo.toml" | ||
orca_whirlpools_client = "^1" | ||
orca_whirlpools_core = "^1" | ||
orca_whirlpools = "^1" | ||
serde_json = { version = "^1.0" } | ||
solana-client = { version = "^1.18" } | ||
solana-sdk = { version = "^1.18" } | ||
tokio = { version = "^1.41.1" } | ||
``` | ||
```rust title="main.rs" | ||
use orca_whirlpools::{ | ||
open_position_instructions, set_whirlpools_config_address, IncreaseLiquidityParam, | ||
WhirlpoolsConfigInput, | ||
}; | ||
use solana_client::nonblocking::rpc_client::RpcClient; | ||
use solana_client::rpc_config::RpcSendTransactionConfig; | ||
use solana_sdk::commitment_config::CommitmentLevel; | ||
use solana_sdk::compute_budget::ComputeBudgetInstruction; | ||
use solana_sdk::message::Message; | ||
use solana_sdk::pubkey::Pubkey; | ||
use solana_sdk::signature::Signature; | ||
use solana_sdk::transaction::Transaction; | ||
use solana_sdk::{signature::Keypair, signer::Signer}; | ||
use std::fs; | ||
use std::str::FromStr; | ||
use tokio::time::{sleep, Duration, Instant}; | ||
``` | ||
</TabItem> | ||
</Tabs> | ||
|
||
|
||
### 1: Configure RPC Client and SDK | ||
To reliably land transactions, avoid using publicly available RPC nodes such as https://api.mainnet-beta.solana.com. Instead, consider using an RPC provider like Helius, Titan, or others. Many providers offer free tiers, which can be sufficient for testing purposes. You may need to adjust the retry mechanism in your code, specifically the waiting time between retries, to accommodate rate limits. | ||
|
||
<Tabs groupId="programming-languages"> | ||
<TabItem value="ts" label="Typescript" default> | ||
```tsx title="sendTransaction.ts" | ||
const rpc = createSolanaRpc("<RPC_URL>"); | ||
await setWhirlpoolsConfig("solanaMainnet"); | ||
``` | ||
</TabItem> | ||
<TabItem value="rust" label="Rust"> | ||
```rust title="main.rs" | ||
#[tokio::main] | ||
async fn main() { | ||
let rpc = RpcClient::new("<RPC_URL>".to_string()); | ||
set_whirlpools_config_address(WhirlpoolsConfigInput::SolanaMainnet).unwrap(); | ||
// Rest of the code | ||
} | ||
``` | ||
</TabItem> | ||
</Tabs> | ||
|
||
|
||
#### What is WhirlpoolsConfig? | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This feels kinda weird here. We are talking about sending and landing transactions. Then all of a sudden there is an explanation about what whirlpoolsconfig is |
||
The `WhirlpoolsConfig` account is the top-level configuration account that governs the pools it owns. The owner of a WhirlpoolsConfig account can define key parameters such as: | ||
|
||
- Default pool fees | ||
- Protocol fee collection authorities | ||
- Other operational parameters for the pools | ||
|
||
All Whirlpools visible on the Orca UI are derived from and controlled by a `WhirlpoolsConfig` account owned by the Orca Foundation. | ||
|
||
The `RPC_URL` should match the network where the specific WhirlpoolsConfig is deployed. For example, if you are working with a `WhirlpoolsConfig` on Solana Mainnet, ensure that the RPC endpoint points to a Solana Mainnet node. | ||
|
||
### 2: Load Wallet | ||
You can [generate a file system wallet using the Solana CLI](https://docs.solanalabs.com/cli/wallets/file-system), which stores a keypair as a JSON file, and load it in your program. You can load the wallet using the following code: | ||
|
||
<Tabs groupId="programming-languages"> | ||
<TabItem value="ts" label="Typescript" default> | ||
```tsx title="sendTransaction.ts" | ||
const keyPairBytes = new Uint8Array(JSON.parse(fs.readFileSync('path/to/solana-keypair.json', 'utf8'))); | ||
const wallet = await createKeyPairSignerFromBytes(keyPairBytes); | ||
``` | ||
</TabItem> | ||
<TabItem value="rust" label="Rust"> | ||
```rust title="main.rs" | ||
#[tokio::main] | ||
async fn main() { | ||
// ... | ||
let wallet_string = fs::read_to_string("wallet.json").unwrap(); | ||
let keypair_bytes: Vec<u8> = serde_json::from_str(&wallet_string).unwrap(); | ||
let wallet = Keypair::from_bytes(&keypair_bytes).unwrap(); | ||
// ... | ||
} | ||
``` | ||
</TabItem> | ||
</Tabs> | ||
|
||
|
||
### 3: Generate Instructions | ||
Next, we generate the necessary instructions for opening a position in a Whirlpool. In this example, we're opening a position with liquidity in the 0.5% fee tier SOL/ORCA pool. We'll be adding 0.1 ORCA (Token B) to the position. Based on the current price of the pool and the price range of the position, the SDK will calculate the estimated amount of SOL needed to match the input amount of ORCA. | ||
|
||
The `openPositionInstructions` function generates the transaction instructions and provides additional details about the operation, including: | ||
|
||
- **Estimated Amount Token A**: This is the estimated maximum amount of SOL that you will deposit into the pool when opening the position. This value reflects the expected result based on the current state of the pool. | ||
- **Initialization Cost**: The one-time non-refundable cost required to initialize the position. You can incur these costs if at least one of the boundaries of the position lies in a tick array account that has not been initialized yet (Read more [here](../../02-Architecture%20Overview/03-Understanding%20Tick%20Arrays.md)). | ||
- **Position Mint**: The mint address for the new liquidity position. Each position has a unique mint that represents the position as an NFT. This keypair must sign the transaction for the position to be successfully initialized. This is done automatically by the Solana web3.js SDK V2, but requires additional steps using the Solana Rust SDK (Step 4). | ||
|
||
Here's how the swap instructions and quote are generated: | ||
|
||
<Tabs groupId="programming-languages"> | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Since we are generating the instructions in other docs pages already. Would it make sense to just take from there (so you don't have to explain any whirlpool stuff). So just start from
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I initially thought to create an all-in-one doc, and I went for openPositionInstructions because it has additional signers. But in the context of the current doc-structure, and the location of this doc, it makes more sense to cut all of that out. |
||
<TabItem value="ts" label="Typescript" default> | ||
```tsx title="sendTransaction.ts" | ||
const whirlpoolAddress = address("5Z66YYYaTmmx1R4mATAGLSc8aV4Vfy5tNdJQzk1GP9RF"); | ||
const param = { tokenB: 100_000n }; | ||
|
||
const { quote, instructions, initializationCost, positionMint } = await openPositionInstructions( | ||
rpc, | ||
whirlpoolAddress, | ||
param, | ||
30, // ORCA/SOL | ||
70, | ||
100, | ||
wallet | ||
); | ||
|
||
console.log(`Quote estimated token max B: ${quote.tokenEstB}`); | ||
console.log(`Initialization cost: ${initializationCost}`); | ||
console.log(`Position mint: ${positionMint}`); | ||
console.log(`Number of instructions: ${instructions.length}`); | ||
``` | ||
</TabItem> | ||
<TabItem value="rust" label="Rust"> | ||
```rust title="main.rs" | ||
#[tokio::main] | ||
async fn main() { | ||
// ... | ||
let whirlpool_address = | ||
Pubkey::from_str("Hxw77h9fEx598afiiZunwHaX3vYu9UskDk9EpPNZp1mG").unwrap(); | ||
|
||
let increase_liquidity_param = IncreaseLiquidityParam::TokenB(100_000); | ||
let open_position_instructions = open_position_instructions( | ||
&rpc, | ||
whirlpool_address, | ||
30.0, | ||
70.0, | ||
increase_liquidity_param, | ||
Some(100), | ||
Some(wallet.pubkey()), | ||
) | ||
.await | ||
.unwrap(); | ||
// ... | ||
} | ||
``` | ||
</TabItem> | ||
</Tabs> | ||
|
||
### 4: Create Transaction Message | ||
To send a transaction on Solana, you need to include a blockhash. A blockhash acts as a timestamp and ensures the transaction has a limited lifetime. Validators use the blockhash to verify the recency of a transaction before including it in a block. A transaction referencing a blockhash is only valid for 150 slots (~1-2 minutes, depending on slot time). After that, the blockhash expires, and the transaction will be rejected. | ||
|
||
Here's how the transaction message is created: | ||
|
||
<Tabs groupId="programming-languages"> | ||
<TabItem value="ts" label="Typescript" default> | ||
```tsx title="sendTransaction.ts" | ||
const latestBlockHash = await rpc.getLatestBlockhash().send(); | ||
const transactionMessage = await pipe( | ||
createTransactionMessage({ version: 0}), | ||
tx => setTransactionMessageFeePayer(wallet.address, tx), | ||
tx => setTransactionMessageLifetimeUsingBlockhash(latestBlockHash.value, tx), | ||
tx => appendTransactionMessageInstructions(instructions, tx) | ||
) | ||
``` | ||
</TabItem> | ||
<TabItem value="rust" label="Rust"> | ||
```rust title="main.rs" | ||
#[tokio::main] | ||
async fn main() { | ||
// ... | ||
let message = Message::new( | ||
&open_position_instructions.instructions, | ||
Some(&wallet.pubkey()), | ||
); | ||
let mut signers: Vec<&dyn Signer> = vec![&wallet]; | ||
signers.extend( | ||
open_position_instructions | ||
.additional_signers | ||
.iter() | ||
.map(|kp| kp as &dyn Signer), | ||
); | ||
let recent_blockhash = rpc.get_latest_blockhash().await.unwrap(); | ||
let transaction = Transaction::new(&signers, message, recent_blockhash); | ||
// ... | ||
} | ||
``` | ||
</TabItem> | ||
</Tabs> | ||
|
||
### 5: Estimating Compute Unit Limit and Prioritization Fee | ||
When preparing a transaction, it's important to set a compute unit limit and an appropriate prioritization fee. Setting the compute units too low and the transaction will fail. Setting it too high will make the transaction less favorable for validators to process.. | ||
|
||
You can get an estimation of the compute units by simulating the transaction on the RPC. To avoid transaction failures caused by underestimating this limit, an additional 100,000 compute units are added, but you can adjust this based on your own tests. | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Might be good to add here that txs that request fewer compute units get high priority for the same amount of prio fee. Thus lower = better but too low and the tx fails |
||
|
||
The prioritization fee incentivizes validators to prioritize your transaction, especially during times of network congestion. You can get a list of recently paid prioritization fees, sorting them and selecting a value from that list. In this example, we select the median of the sorted list. The prioritization fee is provided in micro-lamports per compute unit. The estimated total priority fee is calculated as `estimated compute units * prioritization fee`. However, the actual fee charged will depend on the compute units your transaction ultimately consumes. | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Maybe explain here that we take the 50th percentile in this example but that you can play around with it. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Not 100% sure this is true. I thought it always takes your requested compute units. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I thought this to be true from previous experiences. But I just tested this and you are right. |
||
|
||
<Tabs groupId="programming-languages"> | ||
<TabItem value="ts" label="Typescript" default> | ||
```tsx title="sendTransaction.ts" | ||
const getComputeUnitEstimateForTransactionMessage = | ||
getComputeUnitEstimateForTransactionMessageFactory({ | ||
rpc | ||
}); | ||
const computeUnitEstimate = await getComputeUnitEstimateForTransactionMessage(transactionMessage) + 100_000; | ||
const medianPrioritizationFee = await rpc.getRecentPrioritizationFees() | ||
.send() | ||
.then(fees => fees[Math.floor(fees.length / 2)].prioritizationFee) | ||
const transactionMessageWithComputeUnitInstructions = await prependTransactionMessageInstructions([ | ||
getSetComputeUnitLimitInstruction({ units: computeUnitEstimate }), | ||
getSetComputeUnitPriceInstruction({ microLamports: medianPrioritizationFee }) | ||
], transactionMessage); | ||
``` | ||
</TabItem> | ||
<TabItem value="rust" label="Rust"> | ||
```rust title="main.rs" | ||
#[tokio::main] | ||
async fn main() { | ||
// ... | ||
let simulated_transaction = rpc.simulate_transaction(&transaction).await.unwrap(); | ||
|
||
let mut all_instructions = vec![]; | ||
if let Some(units_consumed) = simulated_transaction.value.units_consumed { | ||
let units_consumed_safe = units_consumed as u32 + 100_000; | ||
let compute_limit_instruction = | ||
ComputeBudgetInstruction::set_compute_unit_limit(units_consumed_safe); | ||
all_instructions.push(compute_limit_instruction); | ||
|
||
let prioritization_fees = rpc | ||
.get_recent_prioritization_fees(&[whirlpool_address]) | ||
.await | ||
.unwrap(); | ||
let mut prioritization_fees_array: Vec<u64> = prioritization_fees | ||
.iter() | ||
.map(|fee| fee.prioritization_fee) | ||
.collect(); | ||
prioritization_fees_array.sort_unstable(); | ||
let prioritization_fee = prioritization_fees_array | ||
.get(prioritization_fees_array.len() / 2) | ||
.cloned(); | ||
|
||
if let Some(prioritization_fee) = prioritization_fee { | ||
let priority_fee_instruction = | ||
ComputeBudgetInstruction::set_compute_unit_price(prioritization_fee); | ||
all_instructions.push(priority_fee_instruction); | ||
} | ||
} | ||
// ... | ||
} | ||
``` | ||
</TabItem> | ||
</Tabs> | ||
|
||
### 6: Sign and Submit Transaction | ||
Finally, the transaction is signed, encoded, and submitted to the network. A client-side retry mechanism ensures that the transaction is repeatedly sent until it is confirmed or the blockhash expires. The same signed transaction is sent in a loop with retries at fixed intervals (100ms), while polling the status using getSignatureStatuses. | ||
|
||
<Tabs groupId="programming-languages"> | ||
<TabItem value="ts" label="Typescript" default> | ||
```tsx title="sendTransaction.ts" | ||
const signedTransaction = await signTransactionMessageWithSigners(transactionMessageWithComputeUnitInstructions) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Maybe good to explain that |
||
const base64EncodedWireTransaction = getBase64EncodedWireTransaction(signedTransaction); | ||
|
||
let blockHashLifetime = 150n; | ||
let currentBlockHeight = latestBlockHash.value.lastValidBlockHeight - blockHashLifetime; | ||
while (latestBlockHash.value.lastValidBlockHeight >= currentBlockHeight) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think in most places we just have a time-based loop. This is advantageous if you send to multiple rpc nodes because some might be running behind more than others (meaning that There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yeah, I know this. My thinking was that this approach shows how blockhashes work, but it makes more sense to give the example with the time-based loop |
||
const signature = await rpc.sendTransaction(base64EncodedWireTransaction, { | ||
maxRetries: 0n, | ||
skipPreflight: true, | ||
encoding: 'base64' | ||
}).send(); | ||
const statuses = await rpc.getSignatureStatuses([signature]).send(); | ||
if (statuses.value[0]) { | ||
if (!statuses.value[0].err) { | ||
console.log(`Transaction confirmed: ${signature}`); | ||
break; | ||
} else { | ||
console.error(`Transaction failed: ${statuses.value[0].err.toString()}`); | ||
break; | ||
} | ||
} | ||
currentBlockHeight = await rpc.getBlockHeight().send(); | ||
await new Promise(resolve => setTimeout(resolve, 100)); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
If you do a time-based loop you could make sure that for example the transaction is not sent more than 1x per second (instead of this manual wait). |
||
} | ||
``` | ||
</TabItem> | ||
<TabItem value="rust" label="Rust"> | ||
```rust title="main.rs" | ||
#[tokio::main] | ||
async fn main() { | ||
// ... | ||
all_instructions.extend(open_position_instructions.instructions); | ||
let message = Message::new(&all_instructions, Some(&wallet.pubkey())); | ||
|
||
let transaction = Transaction::new(&signers, message, recent_blockhash); | ||
let transaction_config = RpcSendTransactionConfig { | ||
skip_preflight: true, | ||
preflight_commitment: Some(CommitmentLevel::Confirmed), | ||
max_retries: Some(0), | ||
..Default::default() | ||
}; | ||
let start_time = Instant::now(); | ||
let timeout = Duration::from_secs(90); | ||
|
||
let send_transaction_result = loop { | ||
if start_time.elapsed() >= timeout { | ||
break Err(Box::<dyn std::error::Error>::from("Transaction timed out")); | ||
} | ||
let signature: Signature = rpc | ||
.send_transaction_with_config(&transaction, transaction_config) | ||
.await | ||
.unwrap(); | ||
let statuses = rpc | ||
.get_signature_statuses(&[signature]) | ||
.await | ||
.unwrap() | ||
.value; | ||
if let Some(status) = statuses[0].clone() { | ||
break Ok((status, signature)); | ||
} | ||
sleep(Duration::from_millis(100)).await; | ||
}; | ||
let signature = send_transaction_result.and_then(|(status, signature)| { | ||
if let Some(err) = status.err { | ||
Err(Box::new(err)) | ||
} else { | ||
Ok(signature) | ||
} | ||
}); | ||
println!("Result: {:?}", signature); | ||
} | ||
``` | ||
</TabItem> | ||
</Tabs> | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Might be nice to add a section about further improvements you could make:
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
perhaps we need a section to explain why the vanila sendAndConfirmTransaction doesn't work reliably and confirm that users can't rely on some wallets' auto-append PF fees (ex. phantom). (i think it's because we appended a signer in our tx somewhere)