1use std::collections::HashMap;
2use std::ops::ControlFlow;
3use std::path::PathBuf;
4use std::str::FromStr;
5use std::time::SystemTime;
6
7use anyhow::{Context, Result, anyhow};
8use bitcoin::hashes::sha256;
9use chrono::{DateTime, Utc};
10use esplora_client::Txid;
11use fedimint_core::config::FederationId;
12use fedimint_core::secp256k1::PublicKey;
13use fedimint_core::util::{backoff_util, retry};
14use fedimint_core::{Amount, BitcoinAmountOrAll, BitcoinHash, default_esplora_server};
15use fedimint_gateway_common::{
16 ChannelInfo, CreateOfferResponse, GatewayBalances, GetInvoiceResponse,
17 ListTransactionsResponse, MnemonicResponse, PaymentDetails, PaymentStatus,
18 PaymentSummaryResponse, V1_API_ENDPOINT,
19};
20use fedimint_ln_server::common::lightning_invoice::Bolt11Invoice;
21use fedimint_lnv2_common::gateway_api::PaymentFee;
22use fedimint_logging::LOG_DEVIMINT;
23use fedimint_testing_core::node_type::LightningNodeType;
24use semver::Version;
25use tracing::{debug, info};
26
27use crate::cmd;
28use crate::envs::{
29 FM_GATEWAY_API_ADDR_ENV, FM_GATEWAY_DATA_DIR_ENV, FM_GATEWAY_LISTEN_ADDR_ENV, FM_PORT_LDK_ENV,
30};
31use crate::external::{Bitcoind, LightningNode};
32use crate::federation::Federation;
33use crate::util::{Command, ProcessHandle, ProcessManager, poll, supports_lnv2};
34use crate::vars::utf8;
35use crate::version_constants::{VERSION_0_6_0_ALPHA, VERSION_0_7_0_ALPHA};
36
37#[derive(Clone)]
38pub enum LdkChainSource {
39 Bitcoind,
40 Esplora,
41}
42
43#[derive(Clone)]
44pub struct Gatewayd {
45 pub(crate) process: ProcessHandle,
46 pub ln: LightningNode,
47 pub addr: String,
48 pub(crate) lightning_node_addr: String,
49 pub gatewayd_version: Version,
50 pub gw_name: String,
51 pub log_path: PathBuf,
52 pub gw_port: u16,
53 pub ldk_port: u16,
54}
55
56impl Gatewayd {
57 pub async fn new(process_mgr: &ProcessManager, ln: LightningNode) -> Result<Self> {
58 let ln_type = ln.ln_type();
59 let (gw_name, port, lightning_node_port) = match &ln {
60 LightningNode::Lnd(_) => (
61 "gatewayd-lnd".to_string(),
62 process_mgr.globals.FM_PORT_GW_LND,
63 process_mgr.globals.FM_PORT_LND_LISTEN,
64 ),
65 LightningNode::Ldk {
66 name,
67 gw_port,
68 ldk_port,
69 chain_source: _,
70 } => (name.to_owned(), gw_port.to_owned(), ldk_port.to_owned()),
71 };
72 let test_dir = &process_mgr.globals.FM_TEST_DIR;
73 let addr = format!("http://127.0.0.1:{port}/{V1_API_ENDPOINT}");
74 let lightning_node_addr = format!("127.0.0.1:{lightning_node_port}");
75
76 let mut gateway_env: HashMap<String, String> = HashMap::from_iter([
77 (
78 FM_GATEWAY_DATA_DIR_ENV.to_owned(),
79 format!("{}/{gw_name}", utf8(test_dir)),
80 ),
81 (
82 FM_GATEWAY_LISTEN_ADDR_ENV.to_owned(),
83 format!("127.0.0.1:{port}"),
84 ),
85 (FM_GATEWAY_API_ADDR_ENV.to_owned(), addr.clone()),
86 (FM_PORT_LDK_ENV.to_owned(), lightning_node_port.to_string()),
87 ]);
88 if !supports_lnv2() {
89 info!(target: LOG_DEVIMINT, "LNv2 is not supported, running gatewayd in LNv1 mode");
90 gateway_env.insert(
91 "FM_GATEWAY_LIGHTNING_MODULE_MODE".to_owned(),
92 "LNv1".to_string(),
93 );
94 }
95 if ln_type == LightningNodeType::Ldk {
96 gateway_env.insert("FM_LDK_ALIAS".to_owned(), gw_name.clone());
97 Self::set_ldk_chain_source(&ln, &mut gateway_env, process_mgr);
98 }
99 let gatewayd_version = crate::util::Gatewayd::version_or_default().await;
100 let process = process_mgr
101 .spawn_daemon(
102 &gw_name,
103 Gatewayd::start_gatewayd(&ln_type, &gatewayd_version).envs(gateway_env),
104 )
105 .await?;
106
107 let log_path = process_mgr
108 .globals
109 .FM_LOGS_DIR
110 .join(format!("{gw_name}.log"));
111 let gatewayd = Self {
112 process,
113 ln,
114 addr,
115 lightning_node_addr,
116 gatewayd_version,
117 gw_name,
118 log_path,
119 gw_port: port,
120 ldk_port: lightning_node_port,
121 };
122 poll(
123 "waiting for gateway to be ready to respond to rpc",
124 || async { gatewayd.gateway_id().await.map_err(ControlFlow::Continue) },
125 )
126 .await?;
127 Ok(gatewayd)
128 }
129
130 fn set_ldk_chain_source(
131 ln: &LightningNode,
132 gateway_env: &mut HashMap<String, String>,
133 process_mgr: &ProcessManager,
134 ) {
135 let network = if let Ok(network) = std::env::var("FM_GATEWAY_NETWORK") {
136 bitcoin::Network::from_str(&network).expect("Invalid network specified")
137 } else {
138 bitcoin::Network::Regtest
139 };
140 if let LightningNode::Ldk {
141 name: _,
142 gw_port: _,
143 ldk_port: _,
144 chain_source,
145 } = ln
146 {
147 match chain_source {
148 LdkChainSource::Bitcoind => {
149 let btc_rpc_port = process_mgr.globals.FM_PORT_BTC_RPC;
150 gateway_env.insert(
151 "FM_LDK_BITCOIND_RPC_URL".to_owned(),
152 format!("http://bitcoin:bitcoin@127.0.0.1:{btc_rpc_port}"),
153 );
154 }
155 LdkChainSource::Esplora => {
156 let esplora_port = process_mgr.globals.FM_PORT_ESPLORA.to_string();
157 gateway_env.insert(
158 "FM_LDK_ESPLORA_SERVER_URL".to_owned(),
159 default_esplora_server(network, Some(esplora_port))
160 .url
161 .to_string(),
162 );
163 }
164 }
165 }
166 }
167
168 fn is_forced_current(&self) -> bool {
169 self.ln.ln_type() == LightningNodeType::Ldk && self.gatewayd_version < *VERSION_0_7_0_ALPHA
170 }
171
172 fn start_gatewayd(ln_type: &LightningNodeType, gatewayd_version: &Version) -> Command {
173 if *ln_type == LightningNodeType::Ldk && *gatewayd_version < *VERSION_0_7_0_ALPHA {
176 cmd!("gatewayd", ln_type)
177 } else {
178 cmd!(crate::util::Gatewayd, ln_type)
179 }
180 }
181
182 pub async fn terminate(self) -> Result<()> {
183 self.process.terminate().await
184 }
185
186 pub fn set_lightning_node(&mut self, ln_node: LightningNode) {
187 self.ln = ln_node;
188 }
189
190 pub async fn stop_lightning_node(&mut self) -> Result<()> {
191 info!(target: LOG_DEVIMINT, "Stopping lightning node");
192 match self.ln.clone() {
193 LightningNode::Lnd(lnd) => lnd.terminate().await,
194 LightningNode::Ldk {
195 name: _,
196 gw_port: _,
197 ldk_port: _,
198 chain_source: _,
199 } => {
200 unimplemented!("LDK node termination not implemented")
203 }
204 }
205 }
206
207 pub async fn restart_with_bin(
210 &mut self,
211 process_mgr: &ProcessManager,
212 gatewayd_path: &PathBuf,
213 gateway_cli_path: &PathBuf,
214 ) -> Result<()> {
215 let ln = self.ln.clone();
216
217 self.process.terminate().await?;
218 unsafe { std::env::set_var("FM_GATEWAYD_BASE_EXECUTABLE", gatewayd_path) };
220 unsafe { std::env::set_var("FM_GATEWAY_CLI_BASE_EXECUTABLE", gateway_cli_path) };
222
223 if supports_lnv2() {
224 info!(target: LOG_DEVIMINT, "LNv2 is now supported, running in All mode");
225 unsafe { std::env::set_var("FM_GATEWAY_LIGHTNING_MODULE_MODE", "All") };
227 }
228
229 let new_ln = ln;
230 let new_gw = Self::new(process_mgr, new_ln.clone()).await?;
231 self.process = new_gw.process;
232 self.set_lightning_node(new_ln);
233 let gatewayd_version = crate::util::Gatewayd::version_or_default().await;
234 let gateway_cli_version = crate::util::GatewayCli::version_or_default().await;
235 info!(
236 target: LOG_DEVIMINT,
237 ?gatewayd_version,
238 ?gateway_cli_version,
239 "upgraded gatewayd and gateway-cli"
240 );
241 Ok(())
242 }
243
244 pub fn cmd(&self) -> Command {
245 if self.is_forced_current() {
246 cmd!(
247 "gateway-cli",
248 "--rpcpassword=theresnosecondbest",
249 "-a",
250 &self.addr
251 )
252 } else {
253 cmd!(
254 crate::util::get_gateway_cli_path(),
255 "--rpcpassword=theresnosecondbest",
256 "-a",
257 &self.addr
258 )
259 }
260 }
261
262 pub async fn get_info(&self) -> Result<serde_json::Value> {
263 retry(
264 "Getting gateway info via gateway-cli info",
265 backoff_util::aggressive_backoff(),
266 || async { cmd!(self, "info").out_json().await },
267 )
268 .await
269 .context("Getting gateway info via gateway-cli info")
270 }
271
272 pub async fn gateway_id(&self) -> Result<String> {
273 let info = self.get_info().await?;
274 let gateway_id = info["gateway_id"]
275 .as_str()
276 .context("gateway_id must be a string")?
277 .to_owned();
278 Ok(gateway_id)
279 }
280
281 pub async fn lightning_pubkey(&self) -> Result<PublicKey> {
282 let info = self.get_info().await?;
283 let lightning_pub_key = info["lightning_pub_key"]
284 .as_str()
285 .context("lightning_pub_key must be a string")?
286 .to_owned();
287 Ok(lightning_pub_key.parse()?)
288 }
289
290 pub async fn connect_fed(&self, fed: &Federation) -> Result<()> {
291 let invite_code = fed.invite_code()?;
292 poll("gateway connect-fed", || async {
293 cmd!(self, "connect-fed", invite_code.clone())
294 .run()
295 .await
296 .map_err(ControlFlow::Continue)?;
297 Ok(())
298 })
299 .await?;
300 Ok(())
301 }
302
303 pub async fn recover_fed(&self, fed: &Federation) -> Result<()> {
304 let federation_id = fed.calculate_federation_id();
305 let invite_code = fed.invite_code()?;
306 info!(target: LOG_DEVIMINT, federation_id = %federation_id, "Recovering...");
307 poll("gateway connect-fed --recover=true", || async {
308 cmd!(self, "connect-fed", invite_code.clone(), "--recover=true")
309 .run()
310 .await
311 .map_err(ControlFlow::Continue)?;
312 Ok(())
313 })
314 .await?;
315 Ok(())
316 }
317
318 pub async fn backup_to_fed(&self, fed: &Federation) -> Result<()> {
319 let federation_id = fed.calculate_federation_id();
320 cmd!(self, "ecash", "backup", "--federation-id", federation_id)
321 .run()
322 .await?;
323 Ok(())
324 }
325
326 pub async fn get_pegin_addr(&self, fed_id: &str) -> Result<String> {
327 Ok(cmd!(self, "ecash", "pegin", "--federation-id={fed_id}")
328 .out_json()
329 .await?
330 .as_str()
331 .context("address must be a string")?
332 .to_owned())
333 }
334
335 pub async fn get_ln_onchain_address(&self) -> Result<String> {
336 cmd!(self, "onchain", "address").out_string().await
337 }
338
339 pub async fn get_mnemonic(&self) -> Result<MnemonicResponse> {
340 let value = retry(
341 "Getting gateway mnemonic",
342 backoff_util::aggressive_backoff(),
343 || async { cmd!(self, "seed").out_json().await },
344 )
345 .await
346 .context("Getting gateway mnemonic")?;
347
348 Ok(serde_json::from_value(value)?)
349 }
350
351 pub async fn leave_federation(&self, federation_id: FederationId) -> Result<()> {
352 cmd!(self, "leave-fed", "--federation-id", federation_id)
353 .run()
354 .await?;
355 Ok(())
356 }
357
358 pub async fn create_invoice(&self, amount_msats: u64) -> Result<Bolt11Invoice> {
359 Ok(Bolt11Invoice::from_str(
360 &cmd!(self, "lightning", "create-invoice", amount_msats)
361 .out_string()
362 .await?,
363 )?)
364 }
365
366 pub async fn pay_invoice(&self, invoice: Bolt11Invoice) -> Result<()> {
367 cmd!(self, "lightning", "pay-invoice", invoice.to_string())
368 .run()
369 .await?;
370
371 Ok(())
372 }
373
374 pub async fn send_ecash(&self, federation_id: String, amount_msats: u64) -> Result<String> {
375 let value = cmd!(
376 self,
377 "ecash",
378 "send",
379 "--federation-id",
380 federation_id,
381 amount_msats
382 )
383 .out_json()
384 .await?;
385 let ecash: String = serde_json::from_value(
386 value
387 .get("notes")
388 .expect("notes key does not exist")
389 .clone(),
390 )?;
391 Ok(ecash)
392 }
393
394 pub async fn receive_ecash(&self, ecash: String) -> Result<()> {
395 cmd!(self, "ecash", "receive", "--notes", ecash)
396 .run()
397 .await?;
398 Ok(())
399 }
400
401 pub async fn get_balances(&self) -> Result<GatewayBalances> {
402 let value = cmd!(self, "get-balances").out_json().await?;
403 Ok(serde_json::from_value(value)?)
404 }
405
406 pub async fn ecash_balance(&self, federation_id: String) -> anyhow::Result<u64> {
407 let federation_id = FederationId::from_str(&federation_id)?;
408 let balances = self.get_balances().await?;
409 let ecash_balance = balances
410 .ecash_balances
411 .into_iter()
412 .find(|info| info.federation_id == federation_id)
413 .ok_or(anyhow::anyhow!("Gateway is not joined to federation"))?
414 .ecash_balance_msats
415 .msats;
416 Ok(ecash_balance)
417 }
418
419 pub async fn send_onchain(
420 &self,
421 bitcoind: &Bitcoind,
422 amount: BitcoinAmountOrAll,
423 fee_rate: u64,
424 ) -> Result<bitcoin::Txid> {
425 let withdraw_address = bitcoind.get_new_address().await?;
426 let value = cmd!(
427 self,
428 "onchain",
429 "send",
430 "--address",
431 withdraw_address,
432 "--amount",
433 amount,
434 "--fee-rate-sats-per-vbyte",
435 fee_rate
436 )
437 .out_json()
438 .await?;
439
440 let txid: bitcoin::Txid = serde_json::from_value(value)?;
441 Ok(txid)
442 }
443
444 pub async fn close_all_channels(&self) -> Result<()> {
445 let channels = self.list_active_channels().await?;
446 for chan in channels {
447 let remote_pubkey = chan.remote_pubkey;
448 cmd!(
449 self,
450 "lightning",
451 "close-channels-with-peer",
452 "--pubkey",
453 remote_pubkey
454 )
455 .run()
456 .await?;
457 }
458
459 Ok(())
460 }
461
462 pub async fn open_channel(
465 &self,
466 gw: &Gatewayd,
467 channel_size_sats: u64,
468 push_amount_sats: Option<u64>,
469 ) -> Result<Txid> {
470 let pubkey = gw.lightning_pubkey().await?;
471
472 let mut command = cmd!(
473 self,
474 "lightning",
475 "open-channel",
476 "--pubkey",
477 pubkey,
478 "--host",
479 gw.lightning_node_addr,
480 "--channel-size-sats",
481 channel_size_sats,
482 "--push-amount-sats",
483 push_amount_sats.unwrap_or(0)
484 );
485
486 Ok(Txid::from_str(&command.out_string().await?)?)
487 }
488
489 pub async fn list_active_channels(&self) -> Result<Vec<ChannelInfo>> {
490 let channels = cmd!(self, "lightning", "list-active-channels")
491 .out_json()
492 .await?;
493 let channels = channels
494 .as_array()
495 .context("channels must be an array")?
496 .iter()
497 .map(|channel| {
498 let remote_pubkey = channel["remote_pubkey"]
499 .as_str()
500 .context("remote_pubkey must be a string")?
501 .to_owned();
502 let channel_size_sats = channel["channel_size_sats"]
503 .as_u64()
504 .context("channel_size_sats must be a u64")?;
505 let outbound_liquidity_sats = channel["outbound_liquidity_sats"]
506 .as_u64()
507 .context("outbound_liquidity_sats must be a u64")?;
508 let inbound_liquidity_sats = channel["inbound_liquidity_sats"]
509 .as_u64()
510 .context("inbound_liquidity_sats must be a u64")?;
511 Ok(ChannelInfo {
512 remote_pubkey: remote_pubkey
513 .parse()
514 .expect("Lightning node returned invalid remote channel pubkey"),
515 channel_size_sats,
516 outbound_liquidity_sats,
517 inbound_liquidity_sats,
518 })
519 })
520 .collect::<Result<Vec<ChannelInfo>>>()?;
521 Ok(channels)
522 }
523
524 pub async fn wait_for_block_height(&self, target_block_height: u64) -> Result<()> {
525 poll("waiting for block height", || async {
526 let info = self.get_info().await.map_err(ControlFlow::Continue)?;
527 let value = info.get("block_height");
528 if let Some(height) = value {
529 let block_height: Option<u32> = serde_json::from_value(height.clone())
530 .context("Could not parse block height")
531 .map_err(ControlFlow::Continue)?;
532 let Some(block_height) = block_height else {
533 return Err(ControlFlow::Continue(anyhow!("Not synced any blocks yet")));
534 };
535 let synced = info["synced_to_chain"]
536 .as_bool()
537 .expect("Could not get synced_to_chain");
538 if block_height >= target_block_height as u32 && synced {
539 return Ok(());
540 }
541 }
542 Err(ControlFlow::Continue(anyhow!("Not synced to block")))
543 })
544 .await?;
545 Ok(())
546 }
547
548 pub async fn get_lightning_fee(&self, fed_id: String) -> Result<PaymentFee> {
549 let gatewayd_version = crate::util::Gatewayd::version_or_default().await;
550 let (fee_key, base_key, ppm_key) =
551 if gatewayd_version >= *VERSION_0_6_0_ALPHA || self.is_forced_current() {
552 ("lightning_fee", "base", "parts_per_million")
553 } else {
554 ("routing_fees", "base_msat", "proportional_millionths")
555 };
556
557 let info_value = self.get_info().await?;
558 let federations = info_value["federations"]
559 .as_array()
560 .expect("federations is an array");
561
562 let fed = federations
563 .iter()
564 .find(|fed| {
565 serde_json::from_value::<String>(fed["federation_id"].clone())
566 .expect("could not deserialize federation_id")
567 == fed_id
568 })
569 .ok_or_else(|| anyhow!("Federation not found"))?;
570
571 let lightning_fee = if gatewayd_version >= *VERSION_0_6_0_ALPHA || self.is_forced_current()
572 {
573 fed["config"][fee_key].clone()
574 } else {
575 fed[fee_key].clone()
576 };
577
578 let base: Amount = serde_json::from_value(lightning_fee[base_key].clone())
579 .map_err(|e| anyhow!("Couldnt parse base: {}", e))?;
580 let parts_per_million: u64 = serde_json::from_value(lightning_fee[ppm_key].clone())
581 .map_err(|e| anyhow!("Couldnt parse parts_per_million: {}", e))?;
582
583 Ok(PaymentFee {
584 base,
585 parts_per_million,
586 })
587 }
588
589 pub async fn set_federation_routing_fee(
590 &self,
591 fed_id: String,
592 base: u64,
593 ppm: u64,
594 ) -> Result<()> {
595 let gatewayd_version = crate::util::Gatewayd::version_or_default().await;
596 if !self.is_forced_current() && gatewayd_version < *VERSION_0_6_0_ALPHA {
597 let new_fed_routing_fees = format!("{fed_id},{base},{ppm}");
598 cmd!(
599 self,
600 "set-configuration",
601 "--per-federation-routing-fees",
602 new_fed_routing_fees
603 )
604 .run()
605 .await?;
606 } else {
607 cmd!(
608 self,
609 "cfg",
610 "set-fees",
611 "--federation-id",
612 fed_id,
613 "--ln-base",
614 base,
615 "--ln-ppm",
616 ppm
617 )
618 .run()
619 .await?;
620 }
621
622 Ok(())
623 }
624
625 pub async fn set_federation_transaction_fee(
626 &self,
627 fed_id: String,
628 base: u64,
629 ppm: u64,
630 ) -> Result<()> {
631 let gatewayd_version = crate::util::Gatewayd::version_or_default().await;
632 if gatewayd_version >= *VERSION_0_6_0_ALPHA || self.is_forced_current() {
633 cmd!(
634 self,
635 "cfg",
636 "set-fees",
637 "--federation-id",
638 fed_id,
639 "--tx-base",
640 base,
641 "--tx-ppm",
642 ppm
643 )
644 .run()
645 .await?;
646 }
647
648 Ok(())
649 }
650
651 pub async fn payment_summary(&self) -> Result<PaymentSummaryResponse> {
652 let out_json = cmd!(self, "payment-summary").out_json().await?;
653 Ok(serde_json::from_value(out_json).expect("Could not deserialize PaymentSummaryResponse"))
654 }
655
656 pub async fn wait_bolt11_invoice(&self, payment_hash: Vec<u8>) -> Result<()> {
657 let gatewayd_version = crate::util::Gatewayd::version_or_default().await;
658 if gatewayd_version < *VERSION_0_7_0_ALPHA {
659 if let LightningNode::Lnd(lnd) = &self.ln {
660 return lnd.wait_bolt11_invoice(payment_hash).await;
661 }
662
663 debug!("Skipping bolt11 invoice check because it is not supported until v0.7");
664 return Ok(());
665 }
666
667 let payment_hash =
668 sha256::Hash::from_slice(&payment_hash).expect("Could not parse payment hash");
669 let invoice_val = cmd!(
670 self,
671 "lightning",
672 "get-invoice",
673 "--payment-hash",
674 payment_hash
675 )
676 .out_json()
677 .await?;
678 let invoice: GetInvoiceResponse =
679 serde_json::from_value(invoice_val).expect("Could not parse GetInvoiceResponse");
680 anyhow::ensure!(invoice.status == PaymentStatus::Succeeded);
681
682 Ok(())
683 }
684
685 pub async fn list_transactions(
686 &self,
687 start: SystemTime,
688 end: SystemTime,
689 ) -> Result<Vec<PaymentDetails>> {
690 let start_datetime: DateTime<Utc> = start.into();
691 let end_datetime: DateTime<Utc> = end.into();
692 let response = cmd!(
693 self,
694 "lightning",
695 "list-transactions",
696 "--start-time",
697 start_datetime.to_rfc3339(),
698 "--end-time",
699 end_datetime.to_rfc3339()
700 )
701 .out_json()
702 .await?;
703 let transactions = serde_json::from_value::<ListTransactionsResponse>(response)?;
704 Ok(transactions.transactions)
705 }
706
707 pub async fn create_offer(&self, amount: Option<Amount>) -> Result<String> {
708 let offer_value = if let Some(amount) = amount {
709 cmd!(
710 self,
711 "lightning",
712 "create-offer",
713 "--amount-msat",
714 amount.msats
715 )
716 .out_json()
717 .await?
718 } else {
719 cmd!(self, "lightning", "create-offer").out_json().await?
720 };
721 let offer_response = serde_json::from_value::<CreateOfferResponse>(offer_value)
722 .expect("Could not parse offer response");
723 Ok(offer_response.offer)
724 }
725
726 pub async fn pay_offer(&self, offer: String, amount: Option<Amount>) -> Result<()> {
727 if let Some(amount) = amount {
728 cmd!(
729 self,
730 "lightning",
731 "pay-offer",
732 "--offer",
733 offer,
734 "--amount-msat",
735 amount.msats
736 )
737 .run()
738 .await?;
739 } else {
740 cmd!(self, "lightning", "pay-offer", "--offer", offer)
741 .run()
742 .await?;
743 }
744
745 Ok(())
746 }
747}