1use crate::{
2 bridge::{
3 be::{U16BE, U64BE},
4 cpi::{self, ntt_with_executor::RelayNttMessageArgs},
5 message::{convert_chain_id_to_wormhole, BridgeMessage, NttMessage, WormholeChainId},
6 },
7 config::state::{
8 fee_config::{FeeConfig, FEE_CONFIG_SEED},
9 ntt_config::{verify_ntt_manager, ExpectedNttConfig, EXPECTED_NTT_CONFIG_SEED},
10 },
11 error::IntentTransferError,
12 fees::{PaidInstruction, VerifyAndCollectAccounts},
13 nonce::Nonce,
14 verify::{verify_and_update_nonce, verify_signer_matches_source, verify_symbol_or_mint},
15 INTENT_TRANSFER_SEED,
16};
17use anchor_lang::{prelude::*, solana_program::sysvar::instructions};
18use anchor_spl::{
19 associated_token::AssociatedToken,
20 token::{
21 approve, close_account, spl_token::try_ui_amount_into_amount, transfer_checked, Approve,
22 CloseAccount, Mint, Token, TokenAccount, TransferChecked,
23 },
24};
25use borsh::{BorshDeserialize, BorshSerialize};
26use chain_id::ChainId;
27use solana_intents::Intent;
28
29const BRIDGE_NTT_INTERMEDIATE_SEED: &[u8] = b"bridge_ntt_intermediate";
30const BRIDGE_NTT_NONCE_SEED: &[u8] = b"bridge_ntt_nonce";
31
32#[derive(AnchorSerialize, AnchorDeserialize, Clone)]
33pub struct BridgeNttTokensArgs {
34 pub signed_quote_bytes: [u8; 165],
35 pub pay_destination_ata_rent: bool,
36}
37
38#[derive(Accounts)]
39pub struct Ntt<'info> {
40 pub clock: Sysvar<'info, Clock>,
42
43 pub rent: Sysvar<'info, Rent>,
45
46 pub ntt_manager: UncheckedAccount<'info>,
48
49 pub ntt_config: UncheckedAccount<'info>,
51
52 #[account(mut)]
54 pub ntt_inbox_rate_limit: UncheckedAccount<'info>,
55
56 pub ntt_session_authority: UncheckedAccount<'info>,
58
59 pub ntt_token_authority: UncheckedAccount<'info>,
61
62 #[account(mut)]
64 pub wormhole_message: UncheckedAccount<'info>,
65
66 pub transceiver: UncheckedAccount<'info>,
68
69 pub emitter: UncheckedAccount<'info>,
71
72 #[account(mut)]
74 pub wormhole_bridge: UncheckedAccount<'info>,
75
76 #[account(mut)]
78 pub wormhole_fee_collector: UncheckedAccount<'info>,
79
80 #[account(mut)]
82 pub wormhole_sequence: UncheckedAccount<'info>,
83
84 pub wormhole_program: UncheckedAccount<'info>,
86
87 #[account(address = cpi::ntt_with_executor::NTT_WITH_EXECUTOR_PROGRAM_ID)]
89 pub ntt_with_executor_program: UncheckedAccount<'info>,
90
91 #[account(address = cpi::ntt_with_executor::EXECUTOR_PROGRAM_ID)]
93 pub executor_program: UncheckedAccount<'info>,
94
95 pub ntt_peer: UncheckedAccount<'info>,
97
98 #[account(mut)]
100 pub ntt_outbox_item: Signer<'info>,
101
102 #[account(mut)]
104 pub ntt_outbox_rate_limit: UncheckedAccount<'info>,
105
106 #[account(mut)]
108 pub ntt_custody: UncheckedAccount<'info>,
109
110 #[account(mut)]
112 pub payee_ntt_with_executor: UncheckedAccount<'info>,
113}
114
115#[derive(Accounts)]
116pub struct BridgeNttTokens<'info> {
117 #[account(seeds = [chain_id::SEED], seeds::program = chain_id::ID, bump)]
118 pub from_chain_id: Account<'info, ChainId>,
119
120 #[account(address = instructions::ID)]
122 pub sysvar_instructions: UncheckedAccount<'info>,
123
124 #[account(seeds = [INTENT_TRANSFER_SEED], bump)]
126 pub intent_transfer_setter: UncheckedAccount<'info>,
127
128 #[account(mut, token::mint = mint)]
129 pub source: Account<'info, TokenAccount>,
130
131 #[account(
132 init_if_needed,
133 payer = sponsor,
134 seeds = [BRIDGE_NTT_INTERMEDIATE_SEED, source.key().as_ref()],
135 bump,
136 token::mint = mint,
137 token::authority = intent_transfer_setter,
138 )]
139 pub intermediate_token_account: Account<'info, TokenAccount>,
140
141 #[account(mut)]
142 pub mint: Account<'info, Mint>,
143
144 pub metadata: Option<UncheckedAccount<'info>>,
145
146 #[account(
147 seeds = [EXPECTED_NTT_CONFIG_SEED, mint.key().as_ref()],
148 bump,
149 )]
150 pub expected_ntt_config: Account<'info, ExpectedNttConfig>,
151
152 #[account(
153 init_if_needed,
154 payer = sponsor,
155 space = Nonce::DISCRIMINATOR.len() + Nonce::INIT_SPACE,
156 seeds = [BRIDGE_NTT_NONCE_SEED, source.owner.key().as_ref()],
157 bump
158 )]
159 pub nonce: Account<'info, Nonce>,
160
161 #[account(mut)]
162 pub sponsor: Signer<'info>,
163
164 #[account(mut, token::mint = fee_mint, token::authority = source.owner )]
165 pub fee_source: Account<'info, TokenAccount>,
166
167 #[account(init_if_needed, payer = sponsor, associated_token::mint = fee_mint, associated_token::authority = sponsor)]
168 pub fee_destination: Account<'info, TokenAccount>,
169
170 pub fee_mint: Account<'info, Mint>,
171
172 pub fee_metadata: Option<UncheckedAccount<'info>>,
173
174 #[account(seeds = [FEE_CONFIG_SEED, fee_mint.key().as_ref()], bump)]
175 pub fee_config: Account<'info, FeeConfig>,
176
177 pub system_program: Program<'info, System>,
178 pub token_program: Program<'info, Token>,
179 pub associated_token_program: Program<'info, AssociatedToken>,
180
181 pub ntt: Ntt<'info>,
183}
184
185impl<'info> PaidInstruction<'info> for BridgeNttTokens<'info> {
186 fn fee_amount(&self) -> u64 {
187 self.fee_config.bridge_transfer_fee
188 }
189
190 fn verify_and_collect_accounts<'a>(&'a self) -> VerifyAndCollectAccounts<'a, 'info> {
191 let Self {
192 fee_source,
193 fee_destination,
194 fee_mint,
195 fee_metadata,
196 intent_transfer_setter,
197 token_program,
198 ..
199 } = self;
200 VerifyAndCollectAccounts {
201 fee_source,
202 fee_destination,
203 fee_mint,
204 fee_metadata,
205 intent_transfer_setter,
206 token_program,
207 }
208 }
209}
210
211impl<'info> BridgeNttTokens<'info> {
213 pub fn verify_and_initiate_bridge(
214 &mut self,
215 signer_seeds: &[&[&[u8]]],
216 args: BridgeNttTokensArgs,
217 ) -> Result<()> {
218 let Intent { message, signer } =
219 Intent::<BridgeMessage>::load(self.sysvar_instructions.as_ref())
220 .map_err(Into::<IntentTransferError>::into)?;
221
222 match message {
223 BridgeMessage::Ntt(ntt_message) => {
224 self.process_ntt_bridge(ntt_message, signer, signer_seeds, args)
225 }
226 }
227 }
228
229 fn process_ntt_bridge(
230 &mut self,
231 ntt_message: NttMessage,
232 signer: Pubkey,
233 signer_seeds: &[&[&[u8]]],
234 args: BridgeNttTokensArgs,
235 ) -> Result<()> {
236 let Self {
237 from_chain_id,
238 intent_transfer_setter,
239 metadata,
240 mint,
241 source,
242 intermediate_token_account,
243 sysvar_instructions: _,
244 token_program,
245 expected_ntt_config,
246 nonce,
247 sponsor,
248 system_program,
249 ntt,
250 ..
251 } = self;
252
253 let Ntt {
254 clock,
255 rent,
256 ntt_manager,
257 ntt_config,
258 ntt_inbox_rate_limit,
259 ntt_session_authority,
260 ntt_token_authority,
261 wormhole_message,
262 transceiver,
263 emitter,
264 wormhole_bridge,
265 wormhole_fee_collector,
266 wormhole_sequence,
267 wormhole_program,
268 ntt_with_executor_program,
269 executor_program,
270 ntt_peer,
271 ntt_outbox_item,
272 ntt_outbox_rate_limit,
273 ntt_custody,
274 payee_ntt_with_executor,
275 } = ntt;
276
277 let NttMessage {
278 version: _,
279 from_chain_id: expected_chain_id,
280 symbol_or_mint,
281 amount: ui_amount,
282 to_chain_id,
283 recipient_address,
284 nonce: new_nonce,
285 fee_amount,
286 fee_symbol_or_mint,
287 } = ntt_message;
288
289 if from_chain_id.chain_id != expected_chain_id {
290 return err!(IntentTransferError::ChainIdMismatch);
291 }
292
293 verify_symbol_or_mint(&symbol_or_mint, metadata, mint)?;
294 verify_signer_matches_source(signer, source.owner)?;
295 verify_and_update_nonce(nonce, new_nonce)?;
296 verify_ntt_manager(ntt_manager.key(), expected_ntt_config)?;
297
298 let amount = try_ui_amount_into_amount(ui_amount, mint.decimals)?;
299
300 transfer_checked(
301 CpiContext::new_with_signer(
302 token_program.to_account_info(),
303 TransferChecked {
304 authority: intent_transfer_setter.to_account_info(),
305 from: source.to_account_info(),
306 mint: mint.to_account_info(),
307 to: intermediate_token_account.to_account_info(),
308 },
309 signer_seeds,
310 ),
311 amount,
312 mint.decimals,
313 )?;
314
315 let to_chain_id_wormhole = convert_chain_id_to_wormhole(&to_chain_id)
316 .ok_or(IntentTransferError::UnsupportedToChainId)?;
317
318 let transfer_args = cpi::ntt_manager::TransferArgs {
319 amount,
320 recipient_chain: cpi::ntt_manager::ChainId {
321 id: to_chain_id_wormhole.into(),
322 },
323 recipient_address: parse_recipient_address(&recipient_address)?,
324 should_queue: false,
325 };
326
327 approve(
328 CpiContext::new_with_signer(
329 token_program.to_account_info(),
330 Approve {
331 to: intermediate_token_account.to_account_info(),
332 delegate: ntt_session_authority.to_account_info(),
333 authority: intent_transfer_setter.to_account_info(),
334 },
335 signer_seeds,
336 ),
337 amount,
338 )?;
339
340 cpi::ntt_manager::transfer_burn(
341 CpiContext::new(
342 ntt_manager.to_account_info(),
343 cpi::ntt_manager::TransferBurn {
344 payer: sponsor.to_account_info(),
345 config: ntt_config.to_account_info(),
346 mint: mint.to_account_info(),
347 from: intermediate_token_account.to_account_info(),
348 token_program: token_program.to_account_info(),
349 outbox_item: ntt_outbox_item.to_account_info(),
350 outbox_rate_limit: ntt_outbox_rate_limit.to_account_info(),
351 custody: ntt_custody.to_account_info(),
352 system_program: system_program.to_account_info(),
353 inbox_rate_limit: ntt_inbox_rate_limit.to_account_info(),
354 peer: ntt_peer.to_account_info(),
355 session_authority: ntt_session_authority.to_account_info(),
356 token_authority: ntt_token_authority.to_account_info(),
357 },
358 ),
359 transfer_args,
360 ntt_manager.key(),
361 )?;
362
363 cpi::ntt_manager::release_wormhole_outbound(
364 CpiContext::new(
365 ntt_manager.to_account_info(),
366 cpi::ntt_manager::ReleaseWormholeOutbound {
367 payer: sponsor.to_account_info(),
368 config: ntt_config.to_account_info(),
369 outbox_item: ntt_outbox_item.to_account_info(),
370 transceiver: transceiver.to_account_info(),
371 wormhole_message: wormhole_message.to_account_info(),
372 emitter: emitter.to_account_info(),
373 wormhole_bridge: wormhole_bridge.to_account_info(),
374 wormhole_fee_collector: wormhole_fee_collector.to_account_info(),
375 wormhole_sequence: wormhole_sequence.to_account_info(),
376 wormhole_program: wormhole_program.to_account_info(),
377 system_program: system_program.to_account_info(),
378 clock: clock.to_account_info(),
379 rent: rent.to_account_info(),
380 },
381 ),
382 cpi::ntt_manager::ReleaseOutboundArgs {
383 revert_on_delay: true,
384 },
385 ntt_manager.key(),
386 )?;
387
388 let BridgeNttTokensArgs {
389 signed_quote_bytes,
390 pay_destination_ata_rent,
391 } = args;
392
393 let relay_ntt_args = compute_relay_ntt_args(
394 to_chain_id_wormhole,
395 signed_quote_bytes.to_vec(),
396 pay_destination_ata_rent,
397 )?;
398
399 cpi::ntt_with_executor::relay_ntt_message(
400 CpiContext::new(
401 ntt_with_executor_program.to_account_info(),
402 cpi::ntt_with_executor::RelayNttMessage {
403 payer: sponsor.to_account_info(),
404 payee: payee_ntt_with_executor.to_account_info(),
405 ntt_program_id: ntt_manager.to_account_info(),
406 ntt_peer: ntt_peer.to_account_info(),
407 ntt_message: ntt_outbox_item.to_account_info(),
408 executor_program: executor_program.to_account_info(),
409 system_program: system_program.to_account_info(),
410 },
411 ),
412 relay_ntt_args,
413 )?;
414
415 close_account(CpiContext::new_with_signer(
416 token_program.to_account_info(),
417 CloseAccount {
418 account: intermediate_token_account.to_account_info(),
419 destination: sponsor.to_account_info(),
420 authority: intent_transfer_setter.to_account_info(),
421 },
422 signer_seeds,
423 ))?;
424
425 self.verify_and_collect_fee(fee_amount, fee_symbol_or_mint, signer_seeds)
426 }
427}
428
429fn parse_recipient_address(address_str: &str) -> Result<[u8; 32]> {
432 if let Ok(pubkey) = address_str.parse::<Pubkey>() {
434 return Ok(pubkey.to_bytes());
435 }
436
437 let hex_str = address_str.strip_prefix("0x").unwrap_or(address_str);
439
440 let bytes = hex::decode(hex_str).map_err(|_| IntentTransferError::InvalidRecipientAddress)?;
441
442 if bytes.len() > 32 {
443 return err!(IntentTransferError::InvalidRecipientAddress);
444 }
445
446 let mut result = [0u8; 32];
448 let start_idx = 32 - bytes.len();
449 result
450 .get_mut(start_idx..)
451 .expect("We checked start_idx is within bounds")
452 .copy_from_slice(&bytes);
453
454 Ok(result)
455}
456
457fn compute_relay_ntt_args(
459 to_chain_id_wormhole: WormholeChainId,
460 signed_quote_bytes: Vec<u8>,
461 pay_destination_ata_rent: bool,
462) -> Result<RelayNttMessageArgs> {
463 let (msg_value, gas_limit) = if to_chain_id_wormhole == WormholeChainId::Solana {
464 compute_msg_value_and_gas_limit_solana(pay_destination_ata_rent)
465 } else {
466 return Err(IntentTransferError::UnsupportedToChainId.into());
467 };
468
469 let relay_instructions = [
471 [1u8].as_slice(),
472 gas_limit.to_be_bytes().as_slice(),
473 msg_value.to_be_bytes().as_slice(),
474 ]
475 .concat();
476
477 let signed_quote = SignedQuote::try_from_slice(&signed_quote_bytes)
478 .map_err(|_| IntentTransferError::InvalidNttSignedQuote)?;
479 let exec_amount =
480 compute_exec_amount(to_chain_id_wormhole, signed_quote, gas_limit, msg_value)?;
481
482 Ok(RelayNttMessageArgs {
483 recipient_chain: to_chain_id_wormhole.into(),
484 exec_amount,
485 signed_quote_bytes,
486 relay_instructions,
487 })
488}
489
490pub const LAMPORTS_PER_SIGNATURE: u128 = 5000;
491pub const RENT_TOKEN_ACCOUNT_SOLANA: u128 = 2_039_280; fn compute_msg_value_and_gas_limit_solana(pay_destination_ata_rent: bool) -> (u128, u128) {
495 let mut msg_value = 0u128;
496 msg_value = msg_value
497 .saturating_add(2 * LAMPORTS_PER_SIGNATURE + 7 * LAMPORTS_PER_SIGNATURE + 1_400_000);
498 msg_value = msg_value.saturating_add(2 * LAMPORTS_PER_SIGNATURE + 7 * LAMPORTS_PER_SIGNATURE);
499 msg_value = msg_value.saturating_add(5000 + 3_200_000);
500 msg_value = msg_value.saturating_add(5000 + 5_000_000);
501 msg_value = msg_value.saturating_add(5000);
502 if pay_destination_ata_rent {
503 msg_value = msg_value.saturating_add(RENT_TOKEN_ACCOUNT_SOLANA);
504 }
505
506 (msg_value, 250_000)
507}
508
509pub type H160 = [u8; 20];
510
511#[derive(BorshSerialize, BorshDeserialize)]
513pub struct SignedQuoteHeader {
514 pub prefix: [u8; 4],
515 pub quoter_address: H160,
516 pub payee_address: [u8; 32],
517 pub source_chain: U16BE,
518 pub destination_chain: U16BE,
519 pub expiry_time: U64BE,
520}
521
522#[derive(BorshSerialize, BorshDeserialize)]
523pub struct SignedQuote {
524 pub header: SignedQuoteHeader,
525 pub base_fee: U64BE,
526 pub destination_gas_price: U64BE,
527 pub source_price: U64BE,
528 pub destination_price: U64BE,
529 pub signature: [u8; 65],
530}
531
532impl SignedQuote {
533 pub fn try_get_message_body(&self) -> Result<[u8; 100]> {
534 let signed_quote_serialized = self.try_to_vec()?;
535 signed_quote_serialized
536 .get(..100)
537 .and_then(|slice| slice.try_into().ok())
538 .ok_or_else(|| IntentTransferError::InvalidNttSignedQuote.into())
539 }
540
541 pub fn try_get_signature_components(&self) -> Result<(&[u8; 64], u8)> {
543 let signature = &self.signature;
544 let (sig_bytes, recovery_index_bytes) = signature.split_at(64);
545 let sig_array = sig_bytes
546 .try_into()
547 .map_err(|_| IntentTransferError::InvalidNttSignedQuote)?;
548 let recovery_index = recovery_index_bytes
549 .first()
550 .copied()
551 .ok_or(IntentTransferError::InvalidNttSignedQuote)?;
552 Ok((sig_array, recovery_index))
553 }
554}
555
556const DECIMALS_QUOTE: u32 = 10;
557const DECIMALS_MAX: u32 = 18;
558
559fn normalize(amount: u128, decimals_from: u32, decimals_to: u32) -> Result<u128> {
560 if decimals_from > decimals_to {
561 Ok(amount / 10u128.pow(decimals_from - decimals_to))
562 } else {
563 amount
564 .checked_mul(10u128.pow(decimals_to - decimals_from))
565 .ok_or(ProgramError::ArithmeticOverflow.into())
566 }
567}
568
569fn compute_exec_amount(
575 to_chain_id: WormholeChainId,
576 quote: SignedQuote,
577 gas_limit: u128,
578 msg_value: u128,
579) -> Result<u64> {
580 let decimals_destination_native = to_chain_id.decimals_native();
581 let decimals_destination_gas = to_chain_id.decimals_gas_price();
582 let decimals_source_native = WormholeChainId::Fogo.decimals_native();
583
584 let base_fee = u128::from(quote.base_fee);
585 let source_price = u128::from(quote.source_price);
586 let destination_price = u128::from(quote.destination_price);
587 let destination_gas_price = u128::from(quote.destination_gas_price);
588
589 let amount_base = normalize(base_fee, DECIMALS_QUOTE, decimals_source_native)?;
590
591 let gas_limit_cost = gas_limit
626 .checked_mul(destination_gas_price)
627 .ok_or(ProgramError::ArithmeticOverflow)?;
628 let gas_limit_cost_normalized =
629 normalize(gas_limit_cost, decimals_destination_gas, DECIMALS_MAX)?;
630 let amount_gas = normalize(
631 gas_limit_cost_normalized
632 .checked_mul(destination_price)
633 .ok_or(ProgramError::ArithmeticOverflow)?
634 .checked_div(source_price)
635 .ok_or(ProgramError::ArithmeticOverflow)?,
636 DECIMALS_MAX,
637 decimals_source_native,
638 )?;
639
640 let msg_value_normalized = normalize(msg_value, decimals_destination_native, DECIMALS_MAX)?;
646 let amount_msg_value = normalize(
647 msg_value_normalized
648 .checked_mul(destination_price)
649 .ok_or(ProgramError::ArithmeticOverflow)?
650 .checked_div(source_price)
651 .ok_or(ProgramError::ArithmeticOverflow)?,
652 DECIMALS_MAX,
653 decimals_source_native,
654 )?;
655
656 let total_amount = amount_base
657 .checked_add(amount_gas)
658 .ok_or(ProgramError::ArithmeticOverflow)?
659 .checked_add(amount_msg_value)
660 .ok_or(ProgramError::ArithmeticOverflow)?;
661
662 Ok(u64::try_from(total_amount)?)
663}
664
665#[cfg(test)]
666mod tests {
667 use super::*;
668
669 #[test]
670 fn test_compute_exec_amount_solana() {
671 let quote = SignedQuote {
672 header: SignedQuoteHeader {
673 prefix: *b"EQ01",
674 quoter_address: [0u8; 20],
675 payee_address: [0u8; 32],
676 source_chain: U16BE(0u16),
677 destination_chain: U16BE(1u16),
678 expiry_time: U64BE(0u64),
679 },
680 base_fee: U64BE(500_000_000),
681 destination_gas_price: U64BE(10_000),
682 source_price: U64BE(2_000_000_000),
683 destination_price: U64BE(1_531_800_000_000),
684 signature: [0u8; 65],
685 };
686
687 let gas_limit = 250_000u128;
688 let msg_value = 9_705_000u128;
689
690 let result = compute_exec_amount(WormholeChainId::Solana, quote, gas_limit, msg_value);
691
692 assert_eq!(result, Ok(7484974250));
693 }
694
695 #[test]
696 fn test_compute_exec_amount_solana_max_price() {
697 let quote = SignedQuote {
698 header: SignedQuoteHeader {
699 prefix: *b"EQ01",
700 quoter_address: [0u8; 20],
701 payee_address: [0u8; 32],
702 source_chain: U16BE(0u16),
703 destination_chain: U16BE(1u16),
704 expiry_time: U64BE(0u64),
705 },
706 base_fee: U64BE(500_000_000),
707 destination_gas_price: U64BE(10_000),
708 source_price: U64BE(2_000_000_000),
709 destination_price: U64BE(u64::MAX),
710 signature: [0u8; 65],
711 };
712
713 let gas_limit = 250_000u128;
714 let msg_value = 11_744_280u128;
715
716 let result = compute_exec_amount(WormholeChainId::Solana, quote, gas_limit, msg_value);
717
718 assert!(result.is_ok());
719 }
720}