lnlink

iOS app for connecting to lightning nodes
git clone git://jb55.com/lnlink
Log | Files | Refs | Submodules | README | LICENSE

PayView.swift (26906B)


      1 //
      2 //  PayView.swift
      3 //  lightninglink
      4 //
      5 //  Created by William Casarin on 2022-02-05.
      6 //
      7 
      8 import SwiftUI
      9 import Combine
     10 
     11 public struct Offer {
     12     let offer: String
     13     let amount: InvoiceAmount
     14     let decoded: InvoiceDecode
     15 }
     16 
     17 public struct Invoice {
     18     let invstr: String
     19     let amount: InvoiceAmount
     20 }
     21 
     22 public enum ReadyInvoice {
     23     case requested(RequestInvoice)
     24     case direct(Invoice)
     25 
     26     func amount() -> InvoiceAmount {
     27         switch self {
     28         case .direct(let inv):
     29             return inv.amount
     30         case .requested(let invreq):
     31             return invreq.amount()
     32         }
     33     }
     34 }
     35 
     36 public enum RequestInvoice {
     37     case lnurl(LNUrlPay)
     38     case offer(Offer)
     39 
     40     func amount() -> InvoiceAmount {
     41         switch self {
     42         case .lnurl(let lnurlp):
     43             return lnurl_pay_invoice_amount(lnurlp)
     44         case .offer(let offer):
     45             return offer.amount
     46         }
     47     }
     48 }
     49 
     50 public struct PayAmount {
     51     let tip: Int64?
     52     let amount: Int64
     53 
     54     func total() -> Int64 {
     55         return amount + (tip ?? 0)
     56     }
     57 }
     58 
     59 public struct FetchInvoiceReq {
     60     let offer: String
     61     let pay_amt: PayAmount?
     62     let amount: InvoiceAmount
     63     let quantity: Int?
     64     let timeout: Int?
     65 }
     66 
     67 public enum TipSelection {
     68     case none
     69     case fifteen
     70     case twenty
     71     case twenty_five
     72 }
     73 
     74 public enum PayState {
     75     case initial
     76     case decoding(LNSocket?, DecodeType)
     77     case decoded(DecodeType)
     78     case ready(Invoice)
     79     case invoice_request(RequestInvoice)
     80     case auth(LNUrlAuth)
     81 }
     82 
     83 let default_placeholder = "10,000"
     84 
     85 struct PayView: View {
     86     let init_decode_type: DecodeType
     87     let lnlink: LNLink
     88     let rate: ExchangeRate?
     89 
     90     let expiry_timer = Timer.publish(every: 1, on: .main, in: .default).autoconnect()
     91     let pay_timeout_ms: Int32 = 100_000
     92 
     93     @State var pay_result: Pay?
     94     @State var state: PayState = .initial
     95     @State var invoice: Decode?
     96     @State var error: String?
     97     @State var expiry_percent: Double?
     98     @State var custom_amount_input: String = ""
     99     @State var custom_amount_msats: Int64 = 0
    100     @State var current_tip: TipSelection = .none
    101     @State var paying: Bool = false
    102 
    103     @Environment(\.presentationMode) var presentationMode
    104     @Environment (\.colorScheme) var colorScheme: ColorScheme
    105 
    106     init(decode: DecodeType, lnlink: LNLink, rate: ExchangeRate?) {
    107         self.init_decode_type = decode
    108         self.lnlink = lnlink
    109         self.rate = rate
    110     }
    111 
    112     var successView: some View {
    113         VStack() {
    114             Text("Payment Success!").font(.largeTitle)
    115         }
    116     }
    117 
    118     var failView: some View {
    119         VStack() {
    120             Text("Payment Failed").font(.largeTitle)
    121             Text(self.error!)
    122         }
    123     }
    124 
    125     private func dismiss() {
    126         self.presentationMode.wrappedValue.dismiss()
    127     }
    128 
    129     var body: some View {
    130         MainView()
    131     }
    132 
    133     func progress_color() -> Color {
    134         guard let perc = expiry_percent else {
    135             return Color.green
    136         }
    137 
    138         if perc < 0.25 {
    139             return Color.red
    140         } else if perc < 0.5 {
    141             return Color.yellow
    142         }
    143 
    144         return Color.green
    145     }
    146 
    147     func MainView() -> some View {
    148         return VStack(alignment: .hcentered) {
    149             Text("Confirm Payment")
    150                 .font(.largeTitle)
    151                 .padding()
    152 
    153             if self.expiry_percent != nil {
    154                 ProgressView(value: self.expiry_percent! * 100, total: 100)
    155                     .accentColor(progress_color())
    156             }
    157 
    158             if self.invoice != nil {
    159                 let invoice = self.invoice!
    160                 if invoice.description() != nil {
    161                     Text(invoice.description()!)
    162                         .multilineTextAlignment(.center)
    163                         .padding()
    164                 }
    165 
    166                 if invoice.vendor() != nil {
    167                     Text(invoice.vendor()!)
    168                         .font(.callout)
    169                         .foregroundColor(.gray)
    170                 }
    171             }
    172 
    173             if self.invoice != nil && self.invoice!.thumbnail() != nil {
    174                 self.invoice!.thumbnail()!
    175                     .resizable()
    176                     .frame(width: 128, height: 128, alignment: .center)
    177                     .clipShape(Circle())
    178                     .overlay(Circle().stroke(Color.black, lineWidth: 4))
    179                     .padding()
    180             }
    181 
    182             Spacer()
    183 
    184             // Middle area
    185             let ready_invoice = is_ready(state)
    186             if ready_invoice != nil {
    187                 amount_view_inv(ready_invoice!.amount())
    188             }
    189 
    190             Text("\(self.error ?? "")")
    191                 .foregroundColor(Color.red)
    192 
    193             if self.should_show_progress() {
    194                 ProgressView()
    195                     .progressViewStyle(.circular)
    196             }
    197 
    198             Spacer()
    199 
    200             // Bottom area
    201             if !self.paying {
    202                 HStack {
    203                     Button("Cancel") {
    204                         self.dismiss()
    205                     }
    206                     .foregroundColor(Color.red)
    207                     .font(.title)
    208 
    209                     Spacer()
    210                     if should_show_confirmation(ready_invoice?.amount()) {
    211                         Button("Confirm") {
    212                             handle_confirm(ln: nil)
    213                         }
    214                         .foregroundColor(Color.green)
    215                         .font(.title)
    216                     }
    217                 }
    218             }
    219         }
    220         .padding()
    221         .onAppear() {
    222             handle_state_change()
    223         }
    224         .onReceive(self.expiry_timer) { _ in
    225             update_expiry_percent()
    226         }
    227     }
    228 
    229     func should_show_confirmation(_ amt: InvoiceAmount?) -> Bool {
    230         if amt != nil && is_any_amount(amt!) && self.custom_amount_msats == 0 {
    231             return false
    232         }
    233         return should_show_confirm(self.state)
    234     }
    235 
    236     func should_show_progress() -> Bool {
    237         return self.paying || (self.error == nil && is_ready(self.state) == nil)
    238     }
    239 
    240     func tip_percent(_ tip: TipSelection) {
    241         if tip == self.current_tip {
    242             self.current_tip = .none
    243             self.custom_amount_msats = 0
    244             return
    245         }
    246 
    247         self.current_tip = tip
    248         let percent = tip_value(tip)
    249 
    250         if tip == .none {
    251             self.custom_amount_msats = 0
    252             return
    253         }
    254         guard let invoice = self.invoice else {
    255             return
    256         }
    257         guard let amount_msat = invoice.amount_msat() else {
    258             return
    259         }
    260 
    261         self.custom_amount_msats = Int64((Double(amount_msat) * percent))
    262     }
    263 
    264     func tip_view() -> some View {
    265         Group {
    266             Text("Tip?")
    267             HStack {
    268                 let unsel_c: Color = .primary
    269                 let sel_c: Color = .blue
    270 
    271                 Button("15%") {
    272                     tip_percent(.fifteen)
    273                 }
    274                 .buttonStyle(.bordered)
    275                 .foregroundColor(current_tip == .fifteen ? sel_c: unsel_c)
    276 
    277                 Button("20%") {
    278                     tip_percent(.twenty)
    279                 }
    280                 .buttonStyle(.bordered)
    281                 .foregroundColor(current_tip == .twenty ? sel_c: unsel_c)
    282 
    283                 Button("25%") {
    284                     tip_percent(.twenty_five)
    285                 }
    286                 .buttonStyle(.bordered)
    287                 .foregroundColor(current_tip == .twenty_five ? sel_c: unsel_c)
    288             }
    289             .padding()
    290         }
    291     }
    292 
    293     func amount_view_inv(_ amt: InvoiceAmount) -> some View {
    294         Group {
    295             if self.paying {
    296                 Text("Paying...")
    297             } else {
    298                 Text("Pay")
    299             }
    300 
    301             switch amt {
    302             case .min(let min_amt):
    303                 amount_view(min_amt + self.custom_amount_msats, rate: self.rate)
    304 
    305                 Text("\(render_amount_msats(self.custom_amount_msats)) tipped")
    306                     .font(.callout)
    307                     .foregroundColor(.gray)
    308 
    309                 if !self.paying {
    310                     Spacer()
    311                     tip_view()
    312                 }
    313 
    314             case .range(let min_amt, let max_amt):
    315                 if self.paying {
    316                     let amt = self.custom_amount_msats
    317                     amount_view(amt, rate: self.rate)
    318                 } else {
    319                     AmountInput(text: $custom_amount_input, placeholder: default_placeholder) { result in
    320                         if let str = result.msats_str {
    321                             self.custom_amount_input = str
    322                         }
    323                         if let msats = result.msats {
    324                             self.custom_amount_msats = msats
    325                         }
    326 
    327                         if self.custom_amount_input != "" {
    328                             if self.custom_amount_msats < min_amt {
    329                                 self.error = "Amount not allowed, must be higher than \(render_amount_msats(min_amt))"
    330                             } else if self.custom_amount_msats > max_amt {
    331                                 self.error = "Amount not allowed, must be lower than \(render_amount_msats(max_amt))"
    332                             } else {
    333                                 if self.error != nil && self.error!.starts(with: "Amount not allowed") {
    334                                     self.error = nil
    335                                 }
    336                             }
    337                         }
    338                     }
    339                 }
    340 
    341             case .any:
    342                 if self.paying {
    343                     let amt = self.custom_amount_msats
    344                     amount_view(amt, rate: self.rate)
    345                 } else {
    346                     Form {
    347                         AmountInput(text: $custom_amount_input, placeholder: default_placeholder) { parsed in
    348                             if let str = parsed.msats_str {
    349                                 self.custom_amount_input = str
    350                             }
    351                             if let msats = parsed.msats {
    352                                 self.custom_amount_msats = msats
    353                             }
    354                         }
    355                     }
    356                     .frame(height: 100)
    357                 }
    358 
    359             case .amount(let amt):
    360                 amount_view(amt, rate: self.rate)
    361             }
    362 
    363             if !self.paying && self.custom_amount_input != "", let msats = self.custom_amount_msats {
    364                 if let rate = self.rate {
    365                     Text("\(msats_to_fiat(msats: msats, xr: rate))")
    366                         .foregroundColor(.gray)
    367                 }
    368             }
    369         }
    370     }
    371 
    372     func confirm_pay(ln: LNSocket?, inv: String, pay_amt: PayAmount?, timeout_ms: Int32, description: String?) {
    373         let res = confirm_payment(ln: ln, lnlink: self.lnlink, bolt11: inv, pay_amt: pay_amt, timeout_ms: timeout_ms, description: description)
    374         switch res {
    375         case .left(let err):
    376             self.paying = false
    377             self.error = err
    378 
    379         case .right(let pay):
    380             print(pay)
    381             DispatchQueue.main.async {
    382                 self.dismiss()
    383                 NotificationCenter.default.post(name: .sentPayment, object: pay)
    384             }
    385         }
    386     }
    387 
    388     func get_pay_amount(_ amt: InvoiceAmount) -> PayAmount? {
    389         return get_pay_amount_from_input(amt, input_amount: self.custom_amount_msats)
    390     }
    391 
    392     func handle_confirm_lnurl(ln mln: LNSocket?, lnurlp: LNUrlPay) {
    393         let lnurl_amt = lnurl_pay_invoice_amount(lnurlp)
    394         guard let pay_amt = get_pay_amount(lnurl_amt) else {
    395             self.error = "Invalid payment amount for lnurl"
    396             return
    397         }
    398         self.paying = true
    399 
    400         lnurl_fetchinvoice(lnurlp: lnurlp, amount: pay_amt.amount) {
    401             switch $0 {
    402             case .left(let err):
    403                 self.error = err.reason
    404                 self.paying = false
    405             case .right(let lnurl_invoice):
    406                 guard let ret_inv = parseInvoiceString(lnurl_invoice.pr) else {
    407                     self.error = "Invalid lnurl invoice"
    408                     self.paying = false
    409                     return
    410                 }
    411                 switch ret_inv {
    412                 case .invoice(let amt, let invstr):
    413                     if !pay_amount_matches(pay_amt: pay_amt, invoice_amount: amt) {
    414                         self.error = "Returned lnurl invoice doesn't match expected amount"
    415                         self.paying = false
    416                         return
    417                     }
    418 
    419                     DispatchQueue.global(qos: .background).async {
    420                         confirm_pay(ln: mln, inv: invstr, pay_amt: nil, timeout_ms: pay_timeout_ms, description: lnurlp.metadata)
    421                     }
    422                 case .offer:
    423                     self.error = "Got an offer from a lnurl pay request? What?"
    424                     self.paying = false
    425                     return
    426                 case .lnurl:
    427                     self.error = "Got another lnurl from an lnurl pay request? What?"
    428                     self.paying = false
    429                     return
    430                 }
    431             }
    432         }
    433     }
    434 
    435     func handle_confirm_offer(ln mln: LNSocket?, offer: Offer) {
    436         guard let pay_amt = get_pay_amount(offer.amount) else {
    437             self.error = "Expected payment amount for bolt12"
    438             return
    439         }
    440         let req = fetchinvoice_req_from_offer(
    441             offer: offer.decoded,
    442             offer_str: offer.offer,
    443             pay_amt: pay_amt)
    444         switch req {
    445         case .left(let err):
    446             self.error = err
    447         case .right(let req):
    448             let token = self.lnlink.token
    449             self.paying = true
    450             DispatchQueue.global(qos: .background).async {
    451                 let ln = mln ?? LNSocket()
    452                 if mln == nil {
    453                     guard ln.connect_and_init(node_id: self.lnlink.node_id, host: self.lnlink.host) else {
    454                         self.paying = false
    455                         self.error = "Connection failed when fetching invoice"
    456                         return
    457                     }
    458                 }
    459                 switch rpc_fetchinvoice(ln: ln, token: token, req: req) {
    460                 case .failure(let err):
    461                     self.paying = false
    462                     self.error = err.description
    463                 case .success(let fetch_invoice):
    464                     confirm_pay(ln: ln, inv: fetch_invoice.invoice, pay_amt: nil, timeout_ms: pay_timeout_ms, description: nil)
    465                 }
    466             }
    467         }
    468     }
    469     
    470     func handle_confirm_auth(ln mln: LNSocket?, auth: LNUrlAuth) {
    471     }
    472     
    473     func handle_confirm(ln mln: LNSocket?) {
    474         // clear last error on confirm
    475         self.error = nil
    476 
    477         switch self.state {
    478         case .auth(let auth):
    479             return handle_confirm_auth(ln: mln, auth: auth)
    480             
    481         case .invoice_request(let reqinv):
    482             switch reqinv {
    483             case .offer(let offer):
    484                 return handle_confirm_offer(ln: mln, offer: offer)
    485             case .lnurl(let lnurlp):
    486                 return handle_confirm_lnurl(ln: mln, lnurlp: lnurlp)
    487             }
    488 
    489         case .ready(let invoice):
    490             let pay_amt = get_pay_amount(invoice.amount)
    491             self.paying = true
    492             DispatchQueue.global(qos: .background).async {
    493                 confirm_pay(ln: mln, inv: invoice.invstr, pay_amt: pay_amt, timeout_ms: pay_timeout_ms, description: nil)
    494             }
    495 
    496         case .initial: fallthrough
    497         case .decoding: fallthrough
    498         case .decoded:
    499             self.error = "Invalid state: \(self.state)"
    500         }
    501     }
    502 
    503     func is_tip_selected(_ tip: TipSelection) -> Bool {
    504         return tip == self.current_tip
    505     }
    506 
    507     func switch_state(_ state: PayState) {
    508         self.state = state
    509         handle_state_change()
    510     }
    511 
    512     func handle_state_change() {
    513             switch self.state {
    514             case .auth:
    515                 break
    516             case .ready:
    517                 break
    518             case .invoice_request:
    519                 break
    520             case .initial:
    521                 switch_state(.decoding(nil, self.init_decode_type))
    522             case .decoding(let ln, let decode):
    523                 DispatchQueue.global(qos: .background).async {
    524                     self.handle_decode(ln, decode: decode)
    525                 }
    526             case .decoded:
    527                 break
    528             }
    529 
    530     }
    531 
    532     func handle_offer(ln: LNSocket, decoded: InvoiceDecode, inv: String) {
    533         switch handle_bolt12_offer(ln: ln, decoded: decoded, inv: inv) {
    534         case .right(let state):
    535             self.invoice = .invoice(decoded)
    536             switch_state(state)
    537         case .left(let err):
    538             self.error = err
    539         }
    540     }
    541 
    542     func handle_lnurl_payview(ln: LNSocket?, lnurlp: LNUrlPay) {
    543         let decode = decode_lnurlp_metadata(lnurlp)
    544         self.invoice = .lnurlp(decode)
    545 
    546         switch_state(.invoice_request(.lnurl(lnurlp)))
    547     }
    548     
    549     func handle_lnurl_auth(ln: LNSocket, auth: LNUrlAuth) {
    550         switch_state(.auth(auth))
    551     }
    552     
    553     func handle_decode(_ oldln: LNSocket?, decode: DecodeType) {
    554         let ln = oldln ?? LNSocket()
    555         if oldln == nil {
    556             guard ln.connect_and_init(node_id: self.lnlink.node_id, host: self.lnlink.host) else {
    557                 return
    558             }
    559         }
    560 
    561         var inv = ""
    562         switch decode {
    563         case .offer(let s):
    564             inv = s
    565         case .invoice(_, let s):
    566             inv = s
    567         case .lnurl(let lnurl):
    568             handle_lnurl(lnurl) { lnurl in
    569                 switch lnurl {
    570                 case .payRequest(let pay):
    571                     self.handle_lnurl_payview(ln: ln, lnurlp: pay)
    572                     return
    573                 case .none:
    574                     self.error = "Invalid lnurl"
    575                 }
    576             }
    577             return
    578         }
    579 
    580         switch rpc_decode(ln: ln, token: self.lnlink.token, inv: inv) {
    581         case .failure(let fail):
    582             self.error = fail.description
    583         case .success(let decoded):
    584             if decoded.type == "bolt12 offer" {
    585                 self.handle_offer(ln: ln, decoded: decoded, inv: inv)
    586 
    587             } else if decoded.type == "bolt11 invoice" || decoded.type == "bolt12 invoice" {
    588                 var amount: InvoiceAmount = .any
    589                 if decoded.amount_msat != nil {
    590                     guard let amt = decoded.amount_msat else {
    591                         self.error = "invalid msat amount: \(decoded.amount_msat!)"
    592                         return
    593                     }
    594 
    595                     amount = .amount(amt.msat)
    596                 }
    597 
    598                 self.state = .ready(Invoice(invstr: inv, amount: amount))
    599                 self.invoice = .invoice(decoded)
    600                 update_expiry_percent()
    601             } else {
    602                 self.error = "unknown decoded type: \(decoded.type)"
    603             }
    604         }
    605 
    606     }
    607 
    608     func update_expiry_percent() {
    609         if case let .invoice(invoice) = self.invoice {
    610             guard let expiry = get_decode_expiry(invoice) else {
    611                 self.expiry_percent = nil
    612                 return
    613             }
    614 
    615             guard let created_at = invoice.created_at else {
    616                 self.expiry_percent = nil
    617                 return
    618             }
    619 
    620             let now = Int64(Date().timeIntervalSince1970)
    621             let expires_at = created_at + expiry
    622 
    623             guard expiry > 0 else {
    624                 self.expiry_percent = nil
    625                 return
    626             }
    627 
    628             guard now < expires_at else {
    629                 self.error = "Invoice expired"
    630                 self.expiry_percent = nil
    631                 return
    632             }
    633 
    634             guard now >= created_at else {
    635                 self.expiry_percent = 1
    636                 return
    637             }
    638 
    639             let prog = now - created_at
    640             self.expiry_percent = 1.0 - (Double(prog) / Double(expiry))
    641         }
    642 
    643 
    644     }
    645 }
    646 
    647 func fetchinvoice_req_from_offer(offer: InvoiceDecode, offer_str: String, pay_amt: PayAmount) -> Either<String, FetchInvoiceReq> {
    648 
    649     var qty: Int? = nil
    650     if offer.quantity_min != nil {
    651         qty = offer.quantity_min!
    652     }
    653 
    654     // TODO: should we wait longer to fetch an invoice??
    655     let timeout = 10
    656 
    657     if offer.amount_msat != nil {
    658         return .right(.init(
    659             offer: offer_str,
    660             pay_amt: pay_amt,
    661             amount: .any,
    662             quantity: qty,
    663             timeout: timeout
    664         ))
    665     } else {
    666         let amount: InvoiceAmount = .amount(pay_amt.amount)
    667         return .right(.init(
    668             offer: offer_str,
    669             pay_amt: pay_amt,
    670             amount: amount,
    671             quantity: qty,
    672             timeout: timeout
    673         ))
    674     }
    675 }
    676 
    677 func parse_msat(_ s: String) -> Int64? {
    678     let str = s.replacingOccurrences(of: "msat", with: "")
    679     return Int64(str)
    680 }
    681 
    682 public enum Either<L, R> {
    683     case left(L)
    684     case right(R)
    685 
    686     func mapError<L2>(mapper: (L) -> L2) -> Either<L2, R> {
    687         switch self {
    688         case .left(let l1):
    689             return .left(mapper(l1))
    690         case .right(let r):
    691             return .right(r)
    692         }
    693     }
    694 }
    695 
    696 func confirm_payment(ln mln: LNSocket?, lnlink: LNLink, bolt11: String, pay_amt: PayAmount?, timeout_ms: Int32, description: String?) -> Either<String, Pay> {
    697     let ln = mln ?? LNSocket()
    698 
    699     if mln == nil {
    700         guard ln.connect_and_init(node_id: lnlink.node_id, host: lnlink.host) else {
    701             return .left("Failed to connect, please try again!")
    702         }
    703     }
    704 
    705     var amount_msat: Int64? = nil
    706     if pay_amt != nil {
    707         amount_msat = pay_amt!.amount + (pay_amt!.tip ?? 0)
    708     }
    709 
    710     let res = rpc_pay(
    711         ln: ln,
    712         token: lnlink.token,
    713         bolt11: bolt11,
    714         amount_msat: amount_msat,
    715         timeout_ms: timeout_ms,
    716         description: description
    717     )
    718 
    719     switch res {
    720     case .failure(let req_err):
    721         // handle error
    722         let errmsg = req_err.description
    723         return .left(errmsg)
    724 
    725     case .success(let pay):
    726         return .right(pay)
    727     }
    728 }
    729 
    730 func is_ready(_ state: PayState) -> ReadyInvoice? {
    731     switch state {
    732     case .ready(let invoice):
    733         return .direct(invoice)
    734     case .invoice_request(let invreq):
    735         return .requested(invreq)
    736     case .auth: fallthrough
    737     case .initial: fallthrough
    738     case .decoding: fallthrough
    739     case .decoded:
    740         return nil
    741     }
    742 }
    743 
    744 
    745 func render_amount(_ amt: InvoiceAmount) -> String {
    746     switch amt {
    747     case .any:
    748         return "Enter amount"
    749     case .range(let min_amt, let max_amt):
    750         return "\(render_amount_msats(min_amt)) to \(render_amount_msats(max_amt))"
    751     case .amount(let amt):
    752         return "\(render_amount_msats(amt))?"
    753     case .min(let min):
    754         return "\(render_amount_msats(min))?"
    755     }
    756 }
    757 
    758 func render_amount_msats_sep(_ amount: Int64) -> (String, String) {
    759     let formatter = NumberFormatter()
    760     formatter.numberStyle = .decimal
    761 
    762     if amount < 1000 {
    763         let amt_str = formatter.string(from: NSNumber(value: amount))!
    764         return (amt_str, amount == 1 ? "msat" : "msats")
    765     }
    766 
    767     let amt_str = formatter.string(from: NSNumber(value: amount / 1000))!
    768     return (amt_str, (amount/1000) == 1 ? "sat" : "sats")
    769 }
    770 
    771 func render_amount_msats(_ amount: Int64) -> String {
    772     let res = render_amount_msats_sep(amount)
    773     return "\(res.0) \(res.1)"
    774 }
    775 
    776 /*
    777 struct PayView_Previews: PreviewProvider {
    778     @Binding var invoice: Invoice?
    779 
    780     static var previews: some View {
    781         PayView(invoice: self.$invoice)
    782     }
    783 }
    784 */
    785 
    786 func handle_bolt12_offer(ln: LNSocket, decoded: InvoiceDecode, inv: String) -> Either<String, PayState> {
    787     if decoded.amount_msat != nil {
    788         guard let min_amt = decoded.amount_msat else {
    789             return .left("Error parsing amount_msat: '\(decoded.amount_msat!)'")
    790         }
    791         let offer = Offer(offer: inv, amount: .min(min_amt.msat), decoded: decoded)
    792         return .right(.invoice_request(.offer(offer)))
    793     } else {
    794         let offer = Offer(offer: inv, amount: .any, decoded: decoded)
    795         return .right(.invoice_request(.offer(offer)))
    796     }
    797 }
    798 
    799 
    800 func should_show_confirm(_ state: PayState) -> Bool {
    801     switch state {
    802     case .ready: fallthrough
    803     case .auth: fallthrough
    804     case .invoice_request:
    805         return true
    806 
    807     case .decoded: fallthrough
    808     case .initial: fallthrough
    809     case .decoding:
    810         return false
    811     }
    812 }
    813 
    814 
    815 func tip_value(_ tip: TipSelection) -> Double {
    816     switch tip {
    817     case .none: return 0
    818     case .fifteen: return 0.15
    819     case .twenty: return 0.2
    820     case .twenty_five: return 0.25
    821     }
    822 }
    823 
    824 func is_any_amount(_ amt: InvoiceAmount) -> Bool {
    825     switch amt {
    826     case .any:
    827         return true
    828     default:
    829         return false
    830     }
    831 }
    832 
    833 func lnurl_pay_invoice_amount(_ lnurlp: LNUrlPay) -> InvoiceAmount {
    834     let min_amt = Int64(lnurlp.minSendable ?? 1)
    835     let max_amt = Int64(lnurlp.maxSendable ?? 2100000000000000000)
    836     return .range(min_amt, max_amt)
    837 }
    838 
    839 func get_pay_amount_from_input(_ amt: InvoiceAmount, input_amount: Int64) -> PayAmount? {
    840     switch amt {
    841     case .min(let min_amt):
    842         return PayAmount(tip: input_amount, amount: min_amt)
    843     case .range:
    844         return PayAmount(tip: 0, amount: input_amount)
    845     case .any:
    846         return PayAmount(tip: 0, amount: input_amount)
    847     case .amount:
    848         return nil
    849     }
    850 }
    851 
    852 
    853 func pay_amount_matches(pay_amt: PayAmount, invoice_amount: InvoiceAmount) -> Bool
    854 {
    855     switch invoice_amount {
    856     case .amount(let amt):
    857         if pay_amt.total() == amt {
    858             return true
    859         }
    860     case .range(let min_amt, let max_amt):
    861         if pay_amt.total() < min_amt {
    862             return false
    863         }
    864 
    865         if pay_amt.total() > max_amt {
    866             return false
    867         }
    868 
    869         return true
    870     case .min(let min):
    871         if pay_amt.total() < min {
    872             return false
    873         }
    874 
    875         return true
    876 
    877     case .any:
    878 
    879         return true
    880     }
    881 
    882     return false
    883 }
    884 
    885 
    886 
    887 func amount_view(_ msats: Int64, rate mrate: ExchangeRate?) -> some View {
    888     Group {
    889         HStack {
    890             let sep = render_amount_msats_sep(msats)
    891             Text(sep.0)
    892                 .font(.largeTitle)
    893                 .fontWeight(.bold)
    894                 .alignmentGuide(.hcentered) {
    895                     $0.width / 2.0
    896                 }
    897 
    898             Text(sep.1)
    899                 .font(.subheadline)
    900         }
    901 
    902         if let rate = mrate {
    903             Text("\(msats_to_fiat(msats: msats, xr: rate))")
    904                 .font(.footnote)
    905                 .foregroundColor(.gray)
    906         }
    907     }
    908 }
    909 
    910 extension HorizontalAlignment {
    911    private enum HCenterAlignment: AlignmentID {
    912       static func defaultValue(in dimensions: ViewDimensions) -> CGFloat {
    913          return dimensions[HorizontalAlignment.center]
    914       }
    915    }
    916    static let hcentered = HorizontalAlignment(HCenterAlignment.self)
    917 }