diff --git a/ui/src/app.tsx b/ui/src/app.tsx index 6d93486..5e4c5fd 100644 --- a/ui/src/app.tsx +++ b/ui/src/app.tsx @@ -9,34 +9,41 @@ import LiquidityDisplay from './components/status/LiquidityDisplay'; import ChannelDisplay from './components/status/ChannelDisplay'; import { VoltProviderContextProvider } from './contexts/VoltProviderContext'; import { HotWalletContextProvider } from './contexts/HotWalletContext'; +import { PaymentUpdateContextProvider } from './contexts/PaymentUpdateContext'; +import { ApiProvider } from './contexts/ApiContext'; export function App() { return ( - - - - -
-
-
- -
-
-
- + + + + + + +
+
+
+
-
- - +
+
+ +
+
+ + +
+
-
-
-
-
-
-
-
+ + + + + + +
+ ); } diff --git a/ui/src/components/commands/InvoiceAndPay.tsx b/ui/src/components/commands/InvoiceAndPay.tsx index abacf44..c03d204 100644 --- a/ui/src/components/commands/InvoiceAndPay.tsx +++ b/ui/src/components/commands/InvoiceAndPay.tsx @@ -1,9 +1,8 @@ -import React, { useState, useContext, memo } from 'react'; +import React, { useState, useContext } from 'react'; import Urbit from '@urbit/http-api'; import { isValidPatp, preSig } from '@urbit/aura' import Button from './shared/Button'; import { FeedbackContext } from '../../contexts/FeedbackContext'; -import Command from '../../types/Command'; import Input from './shared/Input'; import CommandForm from './shared/CommandForm'; import BitcoinAmount from '../../types/BitcoinAmount'; @@ -11,7 +10,7 @@ import Dropdown from './shared/Dropdown'; import Network from '../../types/Network'; const InvoiceAndPay = ({ api }: { api: Urbit }) => { - const { displayCommandSuccess, displayCommandError, displayJsError } = useContext(FeedbackContext); + const { displayJsError, displayJsSuccess } = useContext(FeedbackContext); const [shipInput, setShipInput] = useState('~'); const [ship, setShip] = useState(null); @@ -51,7 +50,7 @@ const InvoiceAndPay = ({ api }: { api: Urbit }) => { displayJsError('Invalid ship'); valid = false; } else if (ship === api.ship || ship === `~${api.ship}`) { - displayJsError("Cannot invoice and pau self") + displayJsError("Cannot invoice and pay self") valid = false; } if (!amount) { @@ -68,9 +67,8 @@ const InvoiceAndPay = ({ api }: { api: Urbit }) => { const invoiceAndPay = async (e: React.FormEvent) => { e.preventDefault(); if (!validateInvoiceAndPayParams()) return; - console.log('ship', ship); try { - const res = await api.thread({ + await api.thread({ inputMark: 'volt-invoice-and-pay-params', outputMark: 'volt-update', threadName: 'api-invoice-and-pay', @@ -83,9 +81,9 @@ const InvoiceAndPay = ({ api }: { api: Urbit }) => { } } ) - console.log('thread done', res); + displayJsSuccess('Thread !api-invoice-and-pay succeeded'); } catch (e) { - displayJsError('Error sending payment'); + displayJsError('Error running thread !api-invoice-and-pay'); console.error(e); } }; diff --git a/ui/src/components/commands/SendPayment.tsx b/ui/src/components/commands/SendPayment.tsx index 4f8c4d3..05ecb63 100644 --- a/ui/src/components/commands/SendPayment.tsx +++ b/ui/src/components/commands/SendPayment.tsx @@ -1,4 +1,4 @@ -import React, { useState, useContext } from 'react'; +import React, { useState, useContext, useMemo } from 'react'; import Urbit from '@urbit/http-api'; import { isValidPatp, preSig } from '@urbit/aura' import Button from './shared/Button'; @@ -6,16 +6,55 @@ import { FeedbackContext } from '../../contexts/FeedbackContext'; import Command from '../../types/Command'; import Input from './shared/Input'; import CommandForm from './shared/CommandForm'; +import BitcoinAmount from '../../types/BitcoinAmount'; +import Text from './shared/Text'; +import { PayreqAmountScryResponse } from '../../types/Response'; + +type DecodedPayreqAmountScryResponse = { + amount: BitcoinAmount | null, + isValid: boolean +} const SendPayment = ({ api }: { api: Urbit }) => { - const { displayCommandSuccess, displayCommandError, displayJsError } = useContext(FeedbackContext); + const { displayCommandSuccess, displayCommandError, displayJsError, displayJsSuccess } = useContext(FeedbackContext); const [payreq, setPayreq] = useState(''); + const [payreqValid, setPayreqValid] = useState(null); + const [payreqAmount, setPayreqAmount] = useState(null); const [shipInput, setShipInput] = useState('~'); const [ship, setShip] = useState(null); - const onChangePayreq = (e: React.ChangeEvent) => { + const getPayreqAmount = async (invoiceString: string): Promise => { + try { + const response: PayreqAmountScryResponse = await api.scry({ + app: "volt", + path: `/utils/payreq/amount/${invoiceString}`, + }); + displayJsSuccess(`Scry /utils/payreq/amount succeeded`); + const amount = (response.msats === null) ? null : new BitcoinAmount(response.msats); + return { amount, isValid: response['is-valid'] }; + } catch (e) { + console.error(e); + displayJsError(`Scry /utils/payreq/amount failed`); + throw e; + } + } + + const onChangePayreq = async (e: React.ChangeEvent) => { setPayreq(e.target.value); + if (e.target.value === '') { + setPayreqValid(null); + setPayreqAmount(null); + return; + } + try { + const { amount, isValid } = await getPayreqAmount(e.target.value); + setPayreqAmount(amount); + setPayreqValid(isValid); + } catch (e) { + setPayreqValid(null); + setPayreqAmount(null); + } }; const onChangeShipInput = (e: React.ChangeEvent) => { @@ -29,7 +68,7 @@ const SendPayment = ({ api }: { api: Urbit }) => { const sendPayment = (e: React.FormEvent) => { e.preventDefault(); - if (!payreq) { + if (!payreq || !payreqValid || !payreqAmount) { displayJsError("Payreq required"); return; } @@ -52,6 +91,20 @@ const SendPayment = ({ api }: { api: Urbit }) => { } }; + const payreqText: string | null = useMemo(() => { + let text = 'Amount: ~'; + if (payreqValid === true) { + if (payreqAmount) { + text = `Amount: ${payreqAmount.displayAsSats()}`; + } else { + text = `Payreq doesn't specify amount`; + } + } else if (payreqValid === false) { + text = 'Payreq is invalid'; + } + return text; + }, [payreqValid, payreqAmount]); + return ( { value={payreq} onChange={onChangePayreq} /> + {payreqText ? : null} (api); export const ApiProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + + useEffect(() => { + const redirectToAuthIfNotLoggedIn = async () => { + try { + await api.scry({ + app: "volt", + path: "/hot-wallet-fee", + }); + } catch (e: any) { + if (e?.status === 403) { + document.location = `${document.location.protocol}//${document.location.host}`; + } + } + } + // Redirect to the auth page happens automatically if this is running from a glob + // So this is only useful for local development + if (process.env.VITE_SHIP_NAME) { + redirectToAuthIfNotLoggedIn(); + } + }, []); + return ( {children} diff --git a/ui/src/contexts/ChannelContext.tsx b/ui/src/contexts/ChannelContext.tsx index bf8765d..aa812fb 100644 --- a/ui/src/contexts/ChannelContext.tsx +++ b/ui/src/contexts/ChannelContext.tsx @@ -17,6 +17,7 @@ interface ChannelContextValue { inboundCapacity: BitcoinAmount; outboundCapacity: BitcoinAmount; channels: Array; + refreshChannelState: () => void; channelsByStatus: { preopening: Channel[]; opening: Channel[]; @@ -37,6 +38,7 @@ export const ChannelContext = createContext({ inboundCapacity: new BitcoinAmount(0), outboundCapacity: new BitcoinAmount(0), channels: [], + refreshChannelState: () => {}, channelsByStatus: { preopening: [], opening: [], @@ -91,6 +93,34 @@ export const ChannelContextProvider: React.FC<{ children: React.ReactNode }> = ( return channelsByStatus.preopening; }, [channelsByStatus]); + + const refreshChannelState = () => { + // Refresh channel state after a short delay; otherwise, in most cases we would refresh + // e.g. after sending payment, balances won't be updated + setTimeout(async () => { + try { + const response = await api.scry({ + app: "volt", + path: "/chan-state", + }); + displayJsSuccess('Scry /chan-state succeeded'); + const { chans: jsonChans }: { 'chans': Array } = response; + const channels: Array = jsonChans.map((chan) => { + return { + ...chan, + his: new BitcoinAmount(chan.his), + our: new BitcoinAmount(chan.our), + } + }); + setChannels(channels); + } catch (e) { + console.error(e); + displayJsError('Scry /chan-state failed'); + } + }, 1000); + }; + + useEffect(() => { const handleAllUpdate = (update: Update) => { if (!subscriptionSuccessful.current) { @@ -110,7 +140,7 @@ export const ChannelContextProvider: React.FC<{ children: React.ReactNode }> = ( displayJsInfo('Got new channel update from /all'); handleNewChannel(update as NewChannelUpdate); } else if (update.type === UpdateType.TempChanUpgraded) { - displayJsInfo('Got channel deleted update from /all'); + displayJsInfo('Got channel upgraded update from /all'); handleTemporaryChannelUpgraded(update as TempChanUpgradedUpdate); } else { console.log('Unimplemented update type', update); @@ -228,6 +258,7 @@ export const ChannelContextProvider: React.FC<{ children: React.ReactNode }> = ( const value = { channels, + refreshChannelState, channelsByStatus, inboundCapacity, outboundCapacity, diff --git a/ui/src/contexts/HotWalletContext.tsx b/ui/src/contexts/HotWalletContext.tsx index 4d43da2..5b398ef 100644 --- a/ui/src/contexts/HotWalletContext.tsx +++ b/ui/src/contexts/HotWalletContext.tsx @@ -2,6 +2,7 @@ import React, { createContext, useContext, useEffect, useRef, useState } from 'r import BitcoinAmount from '../types/BitcoinAmount'; import { ApiContext } from './ApiContext'; import { FeedbackContext } from './FeedbackContext'; +import { HotWalletFeeScryResponse } from '../types/Response'; interface HotWalletContextValue { hotWalletFee: BitcoinAmount | null; @@ -22,7 +23,7 @@ export const HotWalletContextProvider: React.FC<{ children: React.ReactNode }> = if (scryInProgress.current) return; scryInProgress.current = true; try { - const response = await api.scry({ + const response: HotWalletFeeScryResponse = await api.scry({ app: "volt", path: "/hot-wallet-fee", }); diff --git a/ui/src/contexts/PaymentUpdateContext.tsx b/ui/src/contexts/PaymentUpdateContext.tsx new file mode 100644 index 0000000..ad18959 --- /dev/null +++ b/ui/src/contexts/PaymentUpdateContext.tsx @@ -0,0 +1,65 @@ +import React, { createContext, useContext, useEffect, useRef } from 'react'; +import { ApiContext } from './ApiContext'; +import { FeedbackContext } from './FeedbackContext'; +import { Update, UpdateType } from '../types/Update'; +import { ChannelContext } from './ChannelContext'; + + +export const PaymentUpdateContext = createContext(null); + +export const PaymentUpdateContextProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const api = useContext(ApiContext); + const { refreshChannelState } = useContext(ChannelContext); + const { displayJsError, displayJsSuccess, displayJsInfo } = useContext(FeedbackContext); + + const activeSubscription = useRef(false); + const subscriptionSuccessful = useRef(false); + + useEffect(() => { + const handlePaymentUpdate = (update: Update) => { + if (!subscriptionSuccessful.current) { + subscriptionSuccessful.current = true; + displayJsSuccess('Subscription to /payment-updates succeeded'); + } + if (update.type === UpdateType.PaymentUpdate) { + displayJsInfo('Got update from /payment-updates'); + refreshChannelState(); + } + } + + const subscribe = () => { + try { + api.subscribe({ + app: "volt", + path: "/payment-updates", + event: (e) => { + handlePaymentUpdate(e); + }, + err: () => { + displayJsError("Subscription to /payment-updates rejected") + activeSubscription.current = false; + subscriptionSuccessful.current = false; + }, + quit: () => { + displayJsError("Kicked from subscription to /payment-updates") + activeSubscription.current = false; + subscriptionSuccessful.current = false; + }, + }); + activeSubscription.current = true; + } catch (e) { + console.error(e) + displayJsError("Error subscribing to /payment-updates"); + activeSubscription.current = false; + subscriptionSuccessful.current = false; + } + } + subscribe() + }, []) + + return ( + + {children} + + ); +}; diff --git a/ui/src/types/Response.ts b/ui/src/types/Response.ts new file mode 100644 index 0000000..b2d1148 --- /dev/null +++ b/ui/src/types/Response.ts @@ -0,0 +1,28 @@ +import { ChanInfo } from "./Update"; + +export enum ScryResponseType { + HotWalletFee = "hot-wallet-fee", + PayreqAmount = "payreq-amount", + ChanState = "chan-state", +} + +export type ScryResponse = { + type: ScryResponseType; + [key: string]: any; +}; + +export type HotWalletFeeScryResponse = { + type: ScryResponseType.HotWalletFee; + sats: number | null; +}; + +export type PayreqAmountScryResponse = { + type: ScryResponseType.PayreqAmount; + 'is-valid': boolean; + msats: number | null; +}; + +export type ChanStateScryResponse = { + type: ScryResponseType.ChanState; + chans: Array; +}; diff --git a/ui/src/types/Update.ts b/ui/src/types/Update.ts index dc47ed1..3055a13 100644 --- a/ui/src/types/Update.ts +++ b/ui/src/types/Update.ts @@ -4,13 +4,12 @@ import Network from "./Network"; export enum UpdateType { NeedFunding = "need-funding", ChannelState = "channel-state", - ReceivedPayment = "received-payment", + TempChanUpgraded = "temp-chan-upgraded", NewInvoice = "new-invoice", - InvoicePaid = "invoice-paid", PaymentResult = "payment-result", - TempChanUpgraded = "temp-chan-upgraded", NewChannel = "new-channel", InitialState = "initial-state", + PaymentUpdate = "payment-update", } export type Update = { @@ -29,6 +28,11 @@ export type ChannelStateUpdate = { status: ChannelStatus; }; +export type TempChanUpgradedUpdate = { + type: UpdateType.TempChanUpgraded; + id: string; +}; + export type NewInvoiceUpdate = { type: UpdateType.NewInvoice; 'payment-request': { @@ -42,11 +46,6 @@ export type NewChannelUpdate = { 'chan-info': ChanInfo; }; -export type TempChanUpgradedUpdate = { - type: UpdateType.TempChanUpgraded; - id: string; -}; - export type InitialStateUpdate = { type: UpdateType.InitialState; chans: Array; @@ -54,6 +53,10 @@ export type InitialStateUpdate = { invoices: Array; }; +export type PaymentUpdate = { + type: UpdateType.PaymentUpdate; +}; + export type FundingInfo = { 'temporary-channel-id': string; 'tau-address': string; diff --git a/urbit/app/volt.hoon b/urbit/app/volt.hoon index a7d3221..c3e6d9e 100644 --- a/urbit/app/volt.hoon +++ b/urbit/app/volt.hoon @@ -261,30 +261,6 @@ ?+ path (on-watch:def path) [%all ~] ?> (team:title our.bowl src.bowl) - =/ chans=(list chan-info) - %+ turn ~(tap by larv.chan) - |= [=id:bolt l=larva-chan:bolt] - :* id - ship.her.l - initial-msats.our.l - initial-msats.her.l - %preopening - network.our.l - == - =. chans - %+ weld chans - %+ turn ~(tap by live.chan) - |= [=id:bolt c=chan:bolt] - :: confirm LI/FI - =+ our-com=(rear our.commitments.c) - =+ her-com=(rear her.commitments.c) - :* id - ship.her.config.c - balance.our.our-com - balance.her.her-com - state.c - network.our.config.c - == =/ payment-requests=(list payment-request) %+ turn ~(tap by incoming.payments) |= [=hexb:bc =payment-request] @@ -301,7 +277,7 @@ == :: pays :_ this - :~ (give-update [%initial-state chans ~ payment-requests]) + :~ (give-update [%initial-state client-chans:hc ~ payment-requests]) (give-update [%need-funding funding-info]) == [%latest-invoice ~] @@ -335,13 +311,6 @@ |= =path ^- (unit (unit cage)) ?+ path (on-peek:def path) - [%x %hot-wallet-fee ~] - ?. ?=(^ fees.chain) - ``[%volt-update !>([%hot-wallet-fee ~])] - =/ expected-vbytes=@ 127 - =/ fee-estimate (mul expected-vbytes u:fees.chain) - ``[%volt-update !>([%hot-wallet-fee `fee-estimate])] - :: [%x %balance ~] :: note: this ignores balances in channels that are closing and is :: optimisitic on commitment updates @@ -351,6 +320,22 @@ =/ last-commitment (snag (dec (lent our.commitments.chan)) our.commitments.chan) (add out balance.our.last-commitment) ``noun+!>((div total-msats 1.000)) + :: + [%x %chan-state ~] + ``[%volt-response !>([%chan-state client-chans:hc])] + :: + [%x %hot-wallet-fee ~] + ?. ?=(^ fees.chain) + ``[%volt-response !>([%hot-wallet-fee ~])] + =/ expected-vbytes=@ 127 + =/ fee-estimate (mul expected-vbytes u:fees.chain) + ``[%volt-response !>([%hot-wallet-fee `fee-estimate])] + :: + [%x %utils %payreq %amount @t ~] + =/ invoice=(unit invoice:bolt11) (de:bolt11 +>+>-.path) + ?~ invoice ``[%volt-response !>([%payreq-amount | ~])] + ?~ amount.u.invoice ``[%volt-response !>([%payreq-amount & ~])] + ``[%volt-response !>([%payreq-amount & `(amount-to-msats:bolt11 u.amount.u.invoice)])] == :: ++ on-leave on-leave:def @@ -358,6 +343,34 @@ -- :: |_ =bowl:gall +++ client-chans + ^- (list chan-info) + =/ chans=(list chan-info) + %+ turn ~(tap by larv.chan.state) + |= [=id:bolt l=larva-chan:bolt] + :* id + ship.her.l + initial-msats.our.l + initial-msats.her.l + %preopening + network.our.l + == +=. chans + %+ weld chans + %+ turn ~(tap by live.chan.state) + |= [=id:bolt c=chan:bolt] + :: confirm LI/FI + =+ our-com=(rear our.commitments.c) + =+ her-com=(rear her.commitments.c) + :* id + ship.her.config.c + balance.our.our-com + balance.her.her-com + state.c + network.our.config.c + == + chans +:: ++ get-funding-address |= =id:bolt ^- address:bc diff --git a/urbit/lib/volt-json.hoon b/urbit/lib/volt-json.hoon index 8e556d2..9848f86 100644 --- a/urbit/lib/volt-json.hoon +++ b/urbit/lib/volt-json.hoon @@ -80,26 +80,24 @@ |= upd=update:volt ^- json ?+ -.upd (frond 'type' s+'unimplemented') - %hot-wallet-fee - %- pairs - :~ ['type' s+'hot-wallet-fee'] - ['sats' ?^(sats.upd (numb +.sats.upd) ~)] - == - :: %need-funding %- pairs - :~ ['type' s+'need-funding'] - ['funding-info' a+(turn funding-info.upd funding-info)] - - :: (funding-info funding-info.upd)] - == - :: + :~ ['type' s+'need-funding'] + ['funding-info' a+(turn funding-info.upd funding-info)] + == + :: %channel-state %- pairs :~ ['type' s+'channel-state'] ['id' s+`@t`(scot %ud chan-id.upd)] ['status' s+chan-state.upd] == + :: + %temp-chan-upgraded + %- pairs + :~ ['type' s+'temp-chan-upgraded'] + ['id' s+`@t`(scot %ud id.upd)] + == :: %new-invoice %- pairs @@ -112,12 +110,6 @@ :~ ['type' s+'new-channel'] ['chan-info' (chan-info chan-info.upd)] == - :: - %temp-chan-upgraded - %- pairs - :~ ['type' s+'temp-chan-upgraded'] - ['id' s+`@t`(scot %ud id.upd)] - == :: %initial-state %- pairs @@ -126,6 +118,36 @@ ['txs' a+(turn txs.upd pay-info)] ['invoices' a+(turn invoices.upd payment-request)] == + :: + %payment-update + %- pairs + :~ ['type' s+'payment-update'] + == + == + :: + ++ response + |= res=response:volt + ^- json + ?- -.res + %hot-wallet-fee + %- pairs + :~ ['type' s+'hot-wallet-fee'] + ['sats' ?^(sats.res (numb +.sats.res) ~)] + == + :: + %payreq-amount + %- pairs + :~ ['type' s+'payreq-amount'] + ['is-valid' b+is-valid.res] + ['msats' ?^(msats.res (numb u.msats.res) ~)] + == + :: + %chan-state + %- pairs + :~ ['type' s+'payreq-amount'] + ['chans' a+(turn chans.res chan-info)] + == + :: == :: ++ funding-info diff --git a/urbit/mar/volt/response.hoon b/urbit/mar/volt/response.hoon new file mode 100644 index 0000000..7e290be --- /dev/null +++ b/urbit/mar/volt/response.hoon @@ -0,0 +1,14 @@ +/- *volt +/+ volt-json +|_ res=response +++ grab + |% + ++ noun response + -- +++ grow + |% + ++ noun res + ++ json (response:enjs:volt-json res) + -- +++ grad %noun +-- diff --git a/urbit/sur/volt.hoon b/urbit/sur/volt.hoon index 237a3a2..4091454 100644 --- a/urbit/sur/volt.hoon +++ b/urbit/sur/volt.hoon @@ -340,13 +340,10 @@ +$ invoice-and-pay-params [amount=@ud net=?(%regtest %main %testnet) who=@p] :: +$ update - $% [%hot-wallet-fee sats=(unit sats:bc)] - [%need-funding funding-info=(list funding-info)] + $% [%need-funding funding-info=(list funding-info)] [%channel-state =chan-id =chan-state:bolt] [%temp-chan-upgraded id=@] - [%received-payment from=ship =amt=msats] [%new-invoice =payment-request] - [%invoice-paid =payreq] [%payment-result =payreq success=?] [%new-channel =chan-info] $: %initial-state @@ -357,4 +354,10 @@ [%payment-update =payment] [%payment-history log=(map hexb:bc payment)] == +:: ++$ response + $% [%payreq-amount is-valid=? msats=(unit msats)] + [%hot-wallet-fee sats=(unit sats:bc)] + [%chan-state chans=(list chan-info)] + == --