Skip to content

Commit

Permalink
Add experimental support for LNURL pay requests
Browse files Browse the repository at this point in the history
This adds an experimental prompt to support lnurl links currently only
supporting lnurl pay requests.

lnurl pay flow: https://xn--57h.bigsun.xyz/lnurl-pay-flow.txt
  • Loading branch information
bumi committed Aug 16, 2020
1 parent 30de248 commit 816b476
Show file tree
Hide file tree
Showing 7 changed files with 509 additions and 43 deletions.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@
"html-webpack-plugin": "3.2.0",
"husky": "2.1.0",
"jdenticon": "2.1.0",
"js-lnurl": "^0.2.3",
"less": "^3.7.1",
"less-loader": "^4.1.0",
"mini-css-extract-plugin": "^0.4.2",
Expand Down
2 changes: 2 additions & 0 deletions src/app/PromptRoutes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import Loader from 'components/Loader';
import OnboardingPrompt from 'prompts/onboarding';
import AuthorizePrompt from 'prompts/authorize';
import PaymentPrompt from 'prompts/payment';
import LnurlPayPrompt from 'prompts/lnurl';
import InvoicePrompt from 'prompts/invoice';
import SignPrompt from 'prompts/sign';
import VerifyPrompt from 'prompts/verify';
Expand Down Expand Up @@ -39,6 +40,7 @@ class Routes extends React.Component<Props> {
<Route path="/onboarding" exact component={OnboardingPrompt} />
<Route path="/authorize" exact component={AuthorizePrompt} />
<Route path="/payment" exact component={PaymentPrompt} />
<Route path="/lnurl" exact component={LnurlPayPrompt} />
<Route path="/invoice" exact component={InvoicePrompt} />
<Route path="/sign" exact component={SignPrompt} />
<Route path="/verify" exact component={VerifyPrompt} />
Expand Down
125 changes: 125 additions & 0 deletions src/app/prompts/lnurl.less
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
@import '~style/variables.less';

.LnurlPayPrompt {
// Default ant overrides, overridden below
.ant-form-item-label label {
font-size: 0.85rem;
text-transform: uppercase;
font-weight: bold;
letter-spacing: 0.1rem;
color: @text-color-secondary;
}

&-header {
display: flex;
align-items: center;
padding: 1rem;
margin-bottom: 1rem;
background: #fff;
border-bottom: 1px solid #eee;

&-icon {
img {
width: 40px;
height: 40px;
}
}

&-title {
font-size: 1.15rem;
margin: 0 0 0 1rem;
}
}

&-form {
padding: 1rem;

&-value {
// Ant overrides
.ant-input-group.ant-input-group-compact {
display: flex;
}

.ant-select-selection-selected-value {
font-size: 0.85rem;
}

.ant-form-explain {
display: flex;
font-size: 0.75rem;
margin: 0.2rem 0 1rem;

> span {
margin-right: 0.5rem;
}

> .is-fiat {
flex: 1;
text-align: right;
font-size: 0.85rem;
}
}
}

&-memo {
&-text {
font-size: 1rem;
color: rgba(#000, 0.5);

a {
margin-left: 0.5rem;
}
}
}

&-advanced {
&-private {
display: flex;
align-items: center;
transform: translateY(-1rem);
}

.ant-form-item {
flex: 1;
margin-right: 1rem;

&:last-child {
margin-right: 0;
}
}

.ant-form-item-label {
padding-bottom: 0;

label {
display: flex;
align-items: center;
font-size: 0.7rem;
letter-spacing: 0.08rem;

.Help {
margin-left: 0.25rem;
}
}
}

.ant-form-explain {
font-size: 0.7rem;
}
}

&-advancedToggle {
display: block;
text-align: center;
font-size: 0.8rem;
opacity: 0.7;
margin: 0 auto;

&:hover,
&:focus,
&:active {
opacity: 1;
}
}
}
}
218 changes: 218 additions & 0 deletions src/app/prompts/lnurl.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
import React from 'react';
import { connect } from 'react-redux';
import PromptTemplate from 'components/PromptTemplate';
import {
getPromptArgs,
getPromptOrigin,
watchUntilPropChange,
OriginData,
} from 'utils/prompt';
import { removeDomainPrefix } from 'utils/formatters';
import { AppState } from 'store/reducers';
import { getNodeChain } from 'modules/node/selectors';
import { getChainRates } from 'modules/rates/selectors';
import { SendPaymentResponse } from 'webln';
import { PaymentRequestData } from 'modules/payment/types';
import { sendPayment } from 'modules/payment/actions';
import AmountField from 'components/AmountField';
import { Denomination } from 'utils/constants';
import { fromBaseToUnit, fromUnitToBase } from 'utils/units';
import { Form } from 'antd';
import Loader from 'components/Loader';
import './lnurl.less';
import { getParams as getlnurlParams, LNURLPayParams } from 'js-lnurl';

interface StateProps {
paymentRequests: AppState['payment']['paymentRequests'];
sendLightningReceipt: AppState['payment']['sendLightningReceipt'];
isSending: AppState['payment']['isSending'];
sendError: AppState['payment']['sendError'];
denomination: AppState['settings']['denomination'];
fiat: AppState['settings']['fiat'];
isNoFiat: AppState['settings']['isNoFiat'];
rates: ReturnType<typeof getChainRates>;
chain: ReturnType<typeof getNodeChain>;
}

interface DispatchProps {
sendPayment: typeof sendPayment;
}

type Props = StateProps & DispatchProps;

interface State {
lnurlParams: LNURLPayParams;
isLoading: boolean;
value: string;
denomination: Denomination;
routedRequest: PaymentRequestData | null;
paymentRequestValue: string;
}

const INITIAL_STATE = {
lnurlParams: {} as LNURLPayParams,
isLoading: true,
value: '',
denomination: Denomination.SATOSHIS,
paymentRequestValue: '',
routedRequest: null,
};

interface LnurlArgs {
lnurl: string;
}

const notNilNum = (v: string | number | null | undefined): v is string | number =>
!!v || v === 0;

class LnurlPayPrompt extends React.Component<Props, State> {
private args: LnurlArgs;
private origin: OriginData;

constructor(props: Props) {
super(props);
const args = getPromptArgs<LnurlArgs>();
this.args = args;
this.origin = getPromptOrigin();
this.state = INITIAL_STATE;
}

componentDidMount() {
getlnurlParams(this.args.lnurl).then((params: any) => {
const valueSats =
params.minSendable === params.maxSendable ? params.maxSendable : null;
const denomination = Denomination.SATOSHIS;
const value = notNilNum(valueSats)
? fromBaseToUnit(valueSats.toString(), denomination).toString()
: '';
this.setState({
lnurlParams: params,
isLoading: false,
value,
denomination,
});
});
}

render() {
const isConfirmDisabled = this.state.isLoading;
const isValueDisabled =
this.state.lnurlParams &&
this.state.lnurlParams.maxSendable === this.state.lnurlParams.minSendable;

return (
<PromptTemplate
isConfirmDisabled={isConfirmDisabled}
getConfirmData={this.handleConfirm}
>
<div className="LnurlPayPrompt">
<div className="LnurlPayPrompt-header">
<div className="LnurlPayPrompt-header-icon">
<img src={this.origin.icon} />
</div>
<h1 className="LnurlPayPrompt-header-title">
<strong>
Payment request from {removeDomainPrefix(this.origin.domain)}
</strong>
</h1>
</div>
<Form className="LnurlPayPrompt-form">
{this.state.isLoading ? (
<Loader size="5rem" />
) : (
<div>
<AmountField
label="Amount"
amount={this.state.value}
onChangeAmount={this.handleChangeValue}
maximumSats={this.state.lnurlParams.maxSendable.toString()}
minimumSats={this.state.lnurlParams.minSendable.toString()}
disabled={isValueDisabled}
required
autoFocus
showFiat
/>
<div>
<Form.Item className="LnurlPayPrompt-metadata" label="Details">
{this.renderMetadata()}
</Form.Item>
</div>
</div>
)}
</Form>
</div>
</PromptTemplate>
);
}

private renderMetadata = () => {
const text: string = this.state.lnurlParams.decodedMetadata
.filter(([typ, _]: any) => typ === 'text/plain')
.map(([_, content]: any) => content)[0];

const image: string = this.state.lnurlParams.decodedMetadata
.filter(([typ, _]: any) => typ.slice(0, 6) === 'image/')
.map(([typ, content]: any) => `data:${typ},${content}`)[0];

return (
<div className="LnurlPayPrompt-metadata">
{image ? <img src={image} /> : null}
<p>{text}</p>
</div>
);
};

private handleChangeValue = (value: string) => {
this.setState({ value });
};

private handleConfirm = async (): Promise<SendPaymentResponse> => {
const { lnurlParams } = this.state;
const value = fromUnitToBase(this.state.value, this.state.denomination);

const callbackUrl = new URL(lnurlParams.callback);
const queryParams = new URLSearchParams(callbackUrl.search.slice(1));
queryParams.append('amount', value);
callbackUrl.search = queryParams.toString();

const r = await fetch(callbackUrl.toString());
if (r.status >= 300) {
throw new Error(await r.text());
}
const res = await r.json();

if (res.status === 'ERROR') {
throw new Error(res.reason);
}

this.props.sendPayment({
payment_request: res.pr,
amt: value,
});

const receipt = await watchUntilPropChange(
() => this.props.sendLightningReceipt,
() => this.props.sendError,
);

if (!receipt) {
throw new Error('Payment failed to send');
}
return { preimage: receipt.payment_preimage };
};
}

export default connect<StateProps, DispatchProps, {}, AppState>(
state => ({
paymentRequests: state.payment.paymentRequests,
sendLightningReceipt: state.payment.sendLightningReceipt,
isSending: state.payment.isSending,
sendError: state.payment.sendError,
denomination: state.settings.denomination,
fiat: state.settings.fiat,
isNoFiat: state.settings.isNoFiat,
rates: getChainRates(state),
chain: getNodeChain(state),
}),
{ sendPayment },
)(LnurlPayPrompt);
15 changes: 12 additions & 3 deletions src/content_script/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,13 +54,22 @@ if (document) {
const lightningLink = target.closest('[href^="lightning:"]');
if (lightningLink) {
const href = lightningLink.getAttribute('href') as string;
const paymentRequest = href.replace('lightning:', '');
const request = href.replace('lightning:', '');
let promptType = null;
let args = null;
if (request.toLowerCase().startsWith('lnurl')) {
promptType = PROMPT_TYPE.LNURL;
args = { lnurl: request };
} else {
promptType = PROMPT_TYPE.PAYMENT;
args = { paymentRequest: request };
}
browser.runtime.sendMessage({
application: 'Joule',
prompt: true,
type: PROMPT_TYPE.PAYMENT,
type: promptType,
origin: getOriginData(),
args: { paymentRequest },
args,
});
ev.preventDefault();
}
Expand Down
Loading

0 comments on commit 816b476

Please sign in to comment.