Skip to content
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

Merged
merged 13 commits into from
Dec 26, 2024
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ To execute a token swap in an Orca Whirlpool, follow these steps:

await setWhirlpoolsConfig('solanaDevnet');
const devnetRpc = createSolanaRpc(devnet('https://api.devnet.solana.com'));
const wallet = await loadWallet(); // CAUTION: This wallet is not persistent.
const wallet = await loadWallet();
const whirlpoolAddress = address("3KBZiL2g8C7tiJ32hTv5v3KM7aK9htpqTw4cTXz1HvPt");
const mintAddress = address("BRjpCHtyQLNCo8gqRUr8jtdAj5AjPYQaoqbvcZiHok1k");
const inputAmount = 1_000_000n;
Expand Down
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:

Copy link
Collaborator

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)

- 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?
Copy link
Member

Choose a reason for hiding this comment

The 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">
Copy link
Member

Choose a reason for hiding this comment

The 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

const instructions = [] // <- your actual instructions

// build into a transaction
// sing transaction
// etc.

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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.
Copy link
Member

Choose a reason for hiding this comment

The 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.
Copy link
Member

Choose a reason for hiding this comment

The 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.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the actual fee charged will depend on the compute units your transaction ultimately consumes

Not 100% sure this is true. I thought it always takes your requested compute units.

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe good to explain that signTransactionMessageWithSigners only signs with signers you provide. If you provided a noopSigner (for example for a frontend wallet) you need to still manually sign with that wallet.

const base64EncodedWireTransaction = getBase64EncodedWireTransaction(signedTransaction);

let blockHashLifetime = 150n;
let currentBlockHeight = latestBlockHash.value.lastValidBlockHeight - blockHashLifetime;
while (latestBlockHash.value.lastValidBlockHeight >= currentBlockHeight) {
Copy link
Member

Choose a reason for hiding this comment

The 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 rpc.getBlockHeight().send() is never the actual blockheight of the chain)

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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));
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

rpc.sendTransaction and rpc.getSignatureStatuses already take some time to execute (but actual time is not known). Adding the wait here adds on top of that (and 100ms is very low).

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>
Copy link
Member

Choose a reason for hiding this comment

The 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:

  • Sending to multiple rpc nodes concurrently
  • Sending to jito block engine and adding a jito tip
  • swQoS
  • More?

Loading