Skip to main content

acta_maker_sdk/chain/
wsol.rs

1#[cfg(feature = "chain-rpc")]
2use solana_rpc_client::nonblocking::rpc_client::RpcClient;
3#[cfg(feature = "chain-rpc")]
4use solana_sdk::account::Account;
5use solana_sdk::{
6    instruction::{AccountMeta, Instruction},
7    pubkey::Pubkey,
8};
9
10#[cfg(feature = "chain-rpc")]
11use super::ChainError;
12
13/// Default amount left in the maker wallet after wrapping, for fee headroom.
14pub const DEFAULT_SOL_RESERVE_LAMPORTS: u64 = 1_200_000;
15pub const NATIVE_MINT: Pubkey = solana_sdk::pubkey!("So11111111111111111111111111111111111111112");
16
17pub(crate) const TOKEN_PROGRAM_ID: Pubkey =
18    solana_sdk::pubkey!("TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA");
19const SYSTEM_PROGRAM_ID: Pubkey = solana_sdk::pubkey!("11111111111111111111111111111111");
20const SPL_TOKEN_SYNC_NATIVE: u8 = 17;
21const SPL_TOKEN_CLOSE_ACCOUNT: u8 = 9;
22const SYSTEM_INSTRUCTION_TRANSFER: u32 = 2;
23const SYSTEM_INSTRUCTION_CREATE_ACCOUNT_WITH_SEED: u32 = 3;
24const SPL_TOKEN_TRANSFER: u8 = 3;
25const SPL_TOKEN_INITIALIZE_ACCOUNT3: u8 = 18;
26/// Rent-exempt minimum of a 165-byte token account.
27const TOKEN_ACCOUNT_RENT_LAMPORTS: u64 = 2_039_280;
28/// Seed of the owner's temporary wSOL account; it is created and closed in one transaction.
29const UNWRAP_SEED: &str = "acta-unwrap";
30#[cfg(feature = "chain-rpc")]
31const TOKEN_ACCOUNT_MINT_OFFSET: usize = 0;
32#[cfg(feature = "chain-rpc")]
33const TOKEN_ACCOUNT_OWNER_OFFSET: usize = 32;
34#[cfg(feature = "chain-rpc")]
35const TOKEN_ACCOUNT_AMOUNT_OFFSET: usize = 64;
36#[cfg(feature = "chain-rpc")]
37const TOKEN_ACCOUNT_BASE_LEN: usize = 72;
38#[cfg(feature = "chain-rpc")]
39const TOKEN_ACCOUNT_RENT_LEN: usize = 165;
40
41#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
42pub enum NativeSolFunding {
43    #[default]
44    Disabled,
45    WrapIfNeeded {
46        reserve_lamports: u64,
47    },
48}
49
50impl NativeSolFunding {
51    #[must_use]
52    pub const fn with_default_reserve() -> Self {
53        Self::WrapIfNeeded {
54            reserve_lamports: DEFAULT_SOL_RESERVE_LAMPORTS,
55        }
56    }
57}
58
59#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60#[cfg(feature = "chain-rpc")]
61pub(crate) enum WrapDecision {
62    NotNeeded,
63    Wrap {
64        wrap_lamports: u64,
65    },
66    InsufficientNativeSol {
67        needed_lamports: u64,
68        available_lamports: u64,
69    },
70}
71
72#[cfg(feature = "chain-rpc")]
73pub(crate) enum WrapPlan {
74    NotNeeded,
75    Wrap {
76        instructions: Vec<Instruction>,
77        budget: NativeSolBudget,
78    },
79}
80
81#[cfg(feature = "chain-rpc")]
82pub(crate) struct WsolWrapRequest {
83    pub(crate) owner: Pubkey,
84    pub(crate) funding_ata: Pubkey,
85    pub(crate) token_program: Pubkey,
86    pub(crate) expected_settlement: u64,
87    pub(crate) reserve_lamports: u64,
88    pub(crate) allow_account_creation: bool,
89    pub(crate) fee_payer: Pubkey,
90    pub(crate) additional_ata_rent_lamports: u64,
91}
92
93#[derive(Debug, Clone, Copy, PartialEq, Eq)]
94#[cfg(feature = "chain-rpc")]
95pub(crate) struct NativeSolBudget {
96    pub(crate) owner: Pubkey,
97    wrap_lamports: u64,
98    ata_rent_lamports: u64,
99    reserve_lamports: u64,
100}
101
102#[cfg(feature = "chain-rpc")]
103impl NativeSolBudget {
104    fn required_lamports(self, fee_lamports: u64) -> Result<u64, ChainError> {
105        self.wrap_lamports
106            .checked_add(self.ata_rent_lamports)
107            .and_then(|value| value.checked_add(fee_lamports))
108            .and_then(|value| value.checked_add(self.reserve_lamports))
109            .ok_or(ChainError::NativeSolBudgetOverflow)
110    }
111
112    pub(crate) fn validate(
113        self,
114        available_lamports: u64,
115        fee_lamports: u64,
116    ) -> Result<(), ChainError> {
117        let needed_lamports = self.required_lamports(fee_lamports)?;
118        if available_lamports < needed_lamports {
119            return Err(ChainError::InsufficientNativeSol {
120                needed_lamports,
121                available_lamports,
122            });
123        }
124        Ok(())
125    }
126}
127
128#[cfg(feature = "chain-rpc")]
129pub(crate) fn is_native_mint(mint: &Pubkey) -> bool {
130    *mint == NATIVE_MINT
131}
132
133#[cfg(feature = "chain-rpc")]
134pub(crate) fn decide_wrap(
135    existing_wsol: u64,
136    expected_settlement: u64,
137    sol_balance: u64,
138    reserve_lamports: u64,
139    ata_rent_lamports: u64,
140) -> WrapDecision {
141    if existing_wsol >= expected_settlement {
142        return WrapDecision::NotNeeded;
143    }
144    let wrap_lamports = expected_settlement - existing_wsol;
145    let needed_lamports = wrap_lamports
146        .saturating_add(ata_rent_lamports)
147        .saturating_add(reserve_lamports);
148    if sol_balance < needed_lamports {
149        return WrapDecision::InsufficientNativeSol {
150            needed_lamports,
151            available_lamports: sol_balance,
152        };
153    }
154    WrapDecision::Wrap { wrap_lamports }
155}
156
157#[cfg(feature = "chain-rpc")]
158pub(crate) async fn plan_wsol_wrap(
159    rpc: &RpcClient,
160    account: Option<Account>,
161    request: WsolWrapRequest,
162) -> Result<WrapPlan, ChainError> {
163    let WsolWrapRequest {
164        owner,
165        funding_ata,
166        token_program,
167        expected_settlement,
168        reserve_lamports,
169        allow_account_creation,
170        fee_payer,
171        additional_ata_rent_lamports,
172    } = request;
173    let (existing_wsol, ata_rent_lamports) = match account {
174        Some(account) => (parse_wsol_balance(&account, &owner, &token_program)?, 0),
175        None if allow_account_creation => {
176            let rent = if fee_payer == owner {
177                rpc.get_minimum_balance_for_rent_exemption(TOKEN_ACCOUNT_RENT_LEN)
178                    .await?
179            } else {
180                0
181            };
182            (0, rent)
183        }
184        None => return Err(ChainError::MissingFundingAccount(funding_ata)),
185    };
186    if existing_wsol >= expected_settlement {
187        return Ok(WrapPlan::NotNeeded);
188    }
189    let sol_balance = rpc.get_balance(&owner).await?;
190
191    match decide_wrap(
192        existing_wsol,
193        expected_settlement,
194        sol_balance,
195        reserve_lamports,
196        ata_rent_lamports.saturating_add(additional_ata_rent_lamports),
197    ) {
198        WrapDecision::NotNeeded => Ok(WrapPlan::NotNeeded),
199        WrapDecision::InsufficientNativeSol {
200            needed_lamports,
201            available_lamports,
202        } => Err(ChainError::InsufficientNativeSol {
203            needed_lamports,
204            available_lamports,
205        }),
206        WrapDecision::Wrap { wrap_lamports } => Ok(WrapPlan::Wrap {
207            instructions: vec![
208                build_system_transfer_ix(&owner, &funding_ata, wrap_lamports),
209                build_sync_native_ix(&token_program, &funding_ata),
210            ],
211            budget: NativeSolBudget {
212                owner,
213                wrap_lamports,
214                ata_rent_lamports: ata_rent_lamports
215                    .checked_add(additional_ata_rent_lamports)
216                    .ok_or(ChainError::NativeSolBudgetOverflow)?,
217                reserve_lamports,
218            },
219        }),
220    }
221}
222
223#[cfg(feature = "chain-rpc")]
224pub(crate) async fn token_account_rent_lamports(rpc: &RpcClient) -> Result<u64, ChainError> {
225    rpc.get_minimum_balance_for_rent_exemption(TOKEN_ACCOUNT_RENT_LEN)
226        .await
227        .map_err(ChainError::from)
228}
229
230#[cfg(feature = "chain-rpc")]
231fn parse_wsol_balance(
232    account: &Account,
233    expected_owner: &Pubkey,
234    token_program: &Pubkey,
235) -> Result<u64, ChainError> {
236    if account.owner != *token_program || account.data.len() < TOKEN_ACCOUNT_BASE_LEN {
237        return Err(ChainError::InvalidAccountData);
238    }
239    if read_pubkey(&account.data, TOKEN_ACCOUNT_MINT_OFFSET)? != NATIVE_MINT
240        || read_pubkey(&account.data, TOKEN_ACCOUNT_OWNER_OFFSET)? != *expected_owner
241    {
242        return Err(ChainError::InvalidAccountData);
243    }
244    read_u64(&account.data, TOKEN_ACCOUNT_AMOUNT_OFFSET)
245}
246
247#[cfg(feature = "chain-rpc")]
248fn read_pubkey(data: &[u8], offset: usize) -> Result<Pubkey, ChainError> {
249    let bytes: [u8; 32] = data
250        .get(offset..offset + 32)
251        .ok_or(ChainError::InvalidAccountData)?
252        .try_into()
253        .map_err(|_| ChainError::InvalidAccountData)?;
254    Ok(Pubkey::new_from_array(bytes))
255}
256
257#[cfg(feature = "chain-rpc")]
258fn read_u64(data: &[u8], offset: usize) -> Result<u64, ChainError> {
259    let bytes: [u8; 8] = data
260        .get(offset..offset + 8)
261        .ok_or(ChainError::InvalidAccountData)?
262        .try_into()
263        .map_err(|_| ChainError::InvalidAccountData)?;
264    Ok(u64::from_le_bytes(bytes))
265}
266
267/// Builds the explicit SOL-to-wSOL step; callers aggregate batch deficits and budget rent and fees.
268pub fn build_wrap_sol_ixs(owner: Pubkey, fee_payer: Pubkey, lamports: u64) -> Vec<Instruction> {
269    let ata = super::ix::derive_associated_token_address(&owner, &NATIVE_MINT, &TOKEN_PROGRAM_ID);
270    vec![
271        super::ix::create_associated_token_account_idempotent_ix(
272            &fee_payer,
273            &ata,
274            &owner,
275            &NATIVE_MINT,
276            &TOKEN_PROGRAM_ID,
277        ),
278        build_system_transfer_ix(&owner, &ata, lamports),
279        build_sync_native_ix(&TOKEN_PROGRAM_ID, &ata),
280    ]
281}
282
283/// Wraps `lamports` of native SOL and deposits them into the Maker PDA custody; `maker_owner` signs.
284pub fn build_maker_deposit_native_sol_ixs(
285    program_id: &Pubkey,
286    maker_owner: Pubkey,
287    fee_payer: Pubkey,
288    lamports: u64,
289) -> Result<Vec<Instruction>, super::ChainIxError> {
290    let args = super::MakerDepositIxArgs {
291        maker_owner,
292        amount: lamports,
293        premium_mint: NATIVE_MINT,
294        token_program: TOKEN_PROGRAM_ID,
295        create_atas: true,
296    };
297    let mut ixs = super::build_maker_deposit_ixs(program_id, &args, fee_payer)?;
298    let Some(deposit) = ixs.pop() else {
299        return Ok(ixs);
300    };
301    let ata =
302        super::ix::derive_associated_token_address(&maker_owner, &NATIVE_MINT, &TOKEN_PROGRAM_ID);
303    ixs.push(build_system_transfer_ix(&maker_owner, &ata, lamports));
304    ixs.push(build_sync_native_ix(&TOKEN_PROGRAM_ID, &ata));
305    ixs.push(deposit);
306    Ok(ixs)
307}
308
309/// Withdraws `lamports` of wSOL from the Maker PDA custody and unwraps exactly that amount: it
310/// moves to a temporary wSOL account derived from the owner, which is closed in the same
311/// transaction. Other wSOL in the owner's account stays wrapped. `maker_owner` signs and fronts
312/// the temporary account's rent, which the close returns.
313pub fn build_maker_withdraw_native_sol_ixs(
314    program_id: &Pubkey,
315    maker_owner: Pubkey,
316    fee_payer: Pubkey,
317    lamports: u64,
318) -> Result<Vec<Instruction>, super::ChainIxError> {
319    let args = super::MakerWithdrawIxArgs {
320        maker_owner,
321        amount: lamports,
322        premium_mint: NATIVE_MINT,
323        token_program: TOKEN_PROGRAM_ID,
324        create_atas: true,
325    };
326    let mut ixs = super::build_maker_withdraw_ixs(program_id, &args, fee_payer)?;
327    let ata =
328        super::ix::derive_associated_token_address(&maker_owner, &NATIVE_MINT, &TOKEN_PROGRAM_ID);
329    let temporary = unwrap_account(&maker_owner);
330    ixs.extend([
331        build_create_account_with_seed_ix(&maker_owner, &temporary),
332        build_initialize_account3_ix(&temporary, &maker_owner),
333        build_token_transfer_ix(&ata, &temporary, &maker_owner, lamports),
334        build_close_account_ix(&TOKEN_PROGRAM_ID, &temporary, &maker_owner),
335    ]);
336    Ok(ixs)
337}
338
339/// The owner's temporary wSOL account used to unwrap an exact amount: `create_with_seed(owner,
340/// "acta-unwrap", token program)`, hashed directly because the fixed seed cannot fail its checks.
341pub fn unwrap_account(owner: &Pubkey) -> Pubkey {
342    use sha2::{Digest, Sha256};
343    let hash = Sha256::new()
344        .chain_update(owner.as_ref())
345        .chain_update(UNWRAP_SEED.as_bytes())
346        .chain_update(TOKEN_PROGRAM_ID.as_ref())
347        .finalize();
348    Pubkey::new_from_array(hash.into())
349}
350
351fn build_create_account_with_seed_ix(owner: &Pubkey, account: &Pubkey) -> Instruction {
352    let mut data = Vec::with_capacity(4 + 32 + 8 + UNWRAP_SEED.len() + 8 + 8 + 32);
353    data.extend_from_slice(&SYSTEM_INSTRUCTION_CREATE_ACCOUNT_WITH_SEED.to_le_bytes());
354    data.extend_from_slice(owner.as_ref());
355    data.extend_from_slice(&(UNWRAP_SEED.len() as u64).to_le_bytes());
356    data.extend_from_slice(UNWRAP_SEED.as_bytes());
357    data.extend_from_slice(&TOKEN_ACCOUNT_RENT_LAMPORTS.to_le_bytes());
358    data.extend_from_slice(&(TOKEN_ACCOUNT_RENT_LEN as u64).to_le_bytes());
359    data.extend_from_slice(TOKEN_PROGRAM_ID.as_ref());
360    Instruction {
361        program_id: SYSTEM_PROGRAM_ID,
362        accounts: vec![
363            AccountMeta::new(*owner, true),
364            AccountMeta::new(*account, false),
365            AccountMeta::new_readonly(*owner, true),
366        ],
367        data,
368    }
369}
370
371fn build_initialize_account3_ix(account: &Pubkey, owner: &Pubkey) -> Instruction {
372    let mut data = Vec::with_capacity(33);
373    data.push(SPL_TOKEN_INITIALIZE_ACCOUNT3);
374    data.extend_from_slice(owner.as_ref());
375    Instruction {
376        program_id: TOKEN_PROGRAM_ID,
377        accounts: vec![
378            AccountMeta::new(*account, false),
379            AccountMeta::new_readonly(NATIVE_MINT, false),
380        ],
381        data,
382    }
383}
384
385fn build_token_transfer_ix(from: &Pubkey, to: &Pubkey, owner: &Pubkey, amount: u64) -> Instruction {
386    let mut data = Vec::with_capacity(9);
387    data.push(SPL_TOKEN_TRANSFER);
388    data.extend_from_slice(&amount.to_le_bytes());
389    Instruction {
390        program_id: TOKEN_PROGRAM_ID,
391        accounts: vec![
392            AccountMeta::new(*from, false),
393            AccountMeta::new(*to, false),
394            AccountMeta::new_readonly(*owner, true),
395        ],
396        data,
397    }
398}
399
400fn build_close_account_ix(token_program: &Pubkey, account: &Pubkey, owner: &Pubkey) -> Instruction {
401    Instruction {
402        program_id: *token_program,
403        accounts: vec![
404            AccountMeta::new(*account, false),
405            AccountMeta::new(*owner, false),
406            AccountMeta::new_readonly(*owner, true),
407        ],
408        data: vec![SPL_TOKEN_CLOSE_ACCOUNT],
409    }
410}
411
412fn build_system_transfer_ix(from: &Pubkey, to: &Pubkey, lamports: u64) -> Instruction {
413    let mut data = Vec::with_capacity(12);
414    data.extend_from_slice(&SYSTEM_INSTRUCTION_TRANSFER.to_le_bytes());
415    data.extend_from_slice(&lamports.to_le_bytes());
416    Instruction {
417        program_id: SYSTEM_PROGRAM_ID,
418        accounts: vec![AccountMeta::new(*from, true), AccountMeta::new(*to, false)],
419        data,
420    }
421}
422
423fn build_sync_native_ix(token_program: &Pubkey, native_account: &Pubkey) -> Instruction {
424    Instruction {
425        program_id: *token_program,
426        accounts: vec![AccountMeta::new(*native_account, false)],
427        data: vec![SPL_TOKEN_SYNC_NATIVE],
428    }
429}
430
431#[cfg(all(test, feature = "chain-rpc"))]
432#[path = "wsol_tests.rs"]
433mod tests;