Merge pull request #143 from apotdevin/ln-url

chore: 🔧 ln-url
This commit is contained in:
Anthony Potdevin 2020-09-23 15:56:34 +02:00 committed by GitHub
commit 05b41dbbbf
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 1112 additions and 5 deletions

View File

@ -38,6 +38,7 @@ module.exports = {
'plugin:prettier/recommended',
],
rules: {
'react/react-in-jsx-scope': 'off',
'@typescript-eslint/ban-types': 'off',
'@typescript-eslint/explicit-module-boundary-types': 'off',
'@typescript-eslint/explicit-function-return-type': 'off',

View File

@ -65,6 +65,7 @@ This repository consists of a **NextJS** server that handles both the backend **
### Management
- LNURL integration: ln-pay and ln-withdraw are available. Ln-auth soon.
- Send and Receive Lightning payments.
- Send and Receive Bitcoin payments.
- Decode lightning payment requests.

6
package-lock.json generated
View File

@ -6906,6 +6906,12 @@
"@types/express": "*"
}
},
"@types/crypto-js": {
"version": "3.1.47",
"resolved": "https://registry.npmjs.org/@types/crypto-js/-/crypto-js-3.1.47.tgz",
"integrity": "sha512-eI6gvpcGHLk3dAuHYnRCAjX+41gMv1nz/VP55wAe5HtmAKDOoPSfr3f6vkMc08ov1S0NsjvUBxDtHHxqQY1LGA==",
"dev": true
},
"@types/express": {
"version": "4.17.6",
"resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.6.tgz",

View File

@ -40,7 +40,9 @@
"apollo-server-micro": "^2.17.0",
"balanceofsatoshis": "^5.47.0",
"bcryptjs": "^2.4.3",
"bech32": "^1.1.4",
"cookie": "^0.4.1",
"crypto-js": "^4.0.0",
"date-fns": "^2.16.1",
"graphql": "^15.3.0",
"graphql-iso-date": "^3.6.1",
@ -96,6 +98,7 @@
"@testing-library/react": "^11.0.4",
"@types/bcryptjs": "^2.4.2",
"@types/cookie": "^0.4.0",
"@types/crypto-js": "^3.1.47",
"@types/graphql-iso-date": "^3.4.0",
"@types/js-cookie": "^2.2.6",
"@types/js-yaml": "^3.12.5",

View File

@ -38,6 +38,8 @@ import { bosResolvers } from './bos/resolvers';
import { bosTypes } from './bos/types';
import { tbaseResolvers } from './tbase/resolvers';
import { tbaseTypes } from './tbase/types';
import { lnUrlResolvers } from './lnurl/resolvers';
import { lnUrlTypes } from './lnurl/types';
const typeDefs = [
generalTypes,
@ -59,6 +61,7 @@ const typeDefs = [
routeTypes,
bosTypes,
tbaseTypes,
lnUrlTypes,
];
const resolvers = merge(
@ -82,7 +85,8 @@ const resolvers = merge(
macaroonResolvers,
networkResolvers,
bosResolvers,
tbaseResolvers
tbaseResolvers,
lnUrlResolvers
);
export const schema = makeExecutableSchema({ typeDefs, resolvers });

View File

@ -13,7 +13,7 @@ import { logger } from 'server/helpers/logger';
import { requestLimiter } from 'server/helpers/rateLimiter';
import { getErrorMsg } from 'server/helpers/helpers';
import { to } from 'server/helpers/async';
import { DecodedType } from 'server/types/ln-service.types';
import { CreateInvoiceType, DecodedType } from 'server/types/ln-service.types';
const KEYSEND_TYPE = '5482373484';
@ -90,7 +90,7 @@ export const invoiceResolvers = {
return date.toISOString();
};
return await to(
return await to<CreateInvoiceType>(
createInvoiceRequest({
lnd,
...(description && { description }),

View File

@ -0,0 +1,243 @@
import { randomBytes } from 'crypto';
import { to } from 'server/helpers/async';
import { logger } from 'server/helpers/logger';
import { requestLimiter } from 'server/helpers/rateLimiter';
import { ContextType } from 'server/types/apiTypes';
import { createInvoice, decodePaymentRequest, pay } from 'ln-service';
import {
CreateInvoiceType,
DecodedType,
PayInvoiceType,
} from 'server/types/ln-service.types';
// import { GetPublicKeyType } from 'server/types/ln-service.types';
// import hmacSHA256 from 'crypto-js/hmac-sha256';
type LnUrlPayResponseType = {
pr?: string;
successAction?: { tag: string };
status?: string;
reason?: string;
};
type LnUrlParams = {
type: string;
url: string;
};
type FetchLnUrlParams = {
url: string;
};
type LnUrlPayType = { callback: string; amount: number; comment: string };
type LnUrlWithdrawType = {
callback: string;
k1: string;
amount: number;
description: string;
};
type PayRequestType = {
callback: string;
maxSendable: string;
minSendable: string;
metadata: string;
commentAllowed: number;
tag: string;
};
type WithdrawRequestType = {
callback: string;
k1: string;
maxWithdrawable: string;
defaultDescription: string;
minWithdrawable: string;
tag: string;
};
type RequestType = PayRequestType | WithdrawRequestType;
type RequestWithType = { isTypeOf: string } & RequestType;
export const lnUrlResolvers = {
Mutation: {
lnUrl: async (
_: undefined,
{ type, url }: LnUrlParams,
context: ContextType
): Promise<string> => {
await requestLimiter(context.ip, 'lnUrl');
// const fullUrl = new URL(url);
// const { lnd } = context;
// if (type === 'login') {
// logger.debug({ type, url });
// const info = await to<GetPublicKeyType>(
// getPublicKey({ lnd, family: 138, index: 0 })
// );
// const hashed = hmacSHA256(fullUrl.host, info.public_key);
// return info.public_key;
// }
logger.debug({ type, url });
return 'confirmed';
},
fetchLnUrl: async (
_: undefined,
{ url }: FetchLnUrlParams,
context: ContextType
) => {
await requestLimiter(context.ip, 'fetchLnUrl');
try {
const response = await fetch(url);
const json = await response.json();
if (json.status === 'ERROR') {
throw new Error(json.reason || 'LnServiceError');
}
return json;
} catch (error) {
logger.error('Error fetching from LnUrl service: %o', error);
throw new Error('ProblemFetchingFromLnUrlService');
}
},
lnUrlPay: async (
_: undefined,
{ callback, amount, comment }: LnUrlPayType,
context: ContextType
) => {
await requestLimiter(context.ip, 'lnUrlPay');
const { lnd } = context;
logger.debug('LnUrlPay initiated with params %o', {
callback,
amount,
comment,
});
const random8byteNonce = randomBytes(8).toString('hex');
const finalUrl = `${callback}?amount=${
amount * 1000
}&nonce=${random8byteNonce}&comment=${comment}`;
let lnServiceResponse: LnUrlPayResponseType = {
status: 'ERROR',
reason: 'FailedToFetchLnService',
};
try {
const response = await fetch(finalUrl);
lnServiceResponse = await response.json();
if (lnServiceResponse.status === 'ERROR') {
throw new Error(lnServiceResponse.reason || 'LnServiceError');
}
} catch (error) {
logger.error('Error paying to LnUrl service: %o', error);
throw new Error('ProblemPayingLnUrlService');
}
logger.debug('LnUrlPay response: %o', lnServiceResponse);
if (!lnServiceResponse.pr) {
logger.error('No invoice in response from LnUrlService');
throw new Error('ProblemPayingLnUrlService');
}
if (lnServiceResponse.successAction) {
const { tag } = lnServiceResponse.successAction;
if (tag !== 'url' && tag !== 'message' && tag !== 'aes') {
logger.error('LnUrlService provided an invalid tag: %o', tag);
throw new Error('InvalidTagFromLnUrlService');
}
}
const decoded = await to<DecodedType>(
decodePaymentRequest({
lnd,
request: lnServiceResponse.pr,
})
);
if (decoded.tokens > amount) {
logger.error(
`Invoice amount ${decoded.tokens} is higher than amount defined ${amount}`
);
throw new Error('LnServiceInvoiceAmountToHigh');
}
const info = await to<PayInvoiceType>(
pay({ lnd, request: lnServiceResponse.pr })
);
if (!info.is_confirmed) {
logger.error(`Failed to pay invoice: ${lnServiceResponse.pr}`);
throw new Error('FailedToPayInvoiceToLnUrlService');
}
return (
lnServiceResponse.successAction || {
tag: 'message',
message: 'Succesfully Paid',
}
);
},
lnUrlWithdraw: async (
_: undefined,
{ callback, k1, amount, description }: LnUrlWithdrawType,
context: ContextType
) => {
await requestLimiter(context.ip, 'lnUrlWithdraw');
const { lnd } = context;
logger.debug('LnUrlWithdraw initiated with params: %o', {
callback,
amount,
k1,
description,
});
// Create invoice to be paid by LnUrlService
const info = await to<CreateInvoiceType>(
createInvoice({ lnd, tokens: amount, description })
);
const finalUrl = `${callback}?k1=${k1}&pr=${info.request}`;
try {
const response = await fetch(finalUrl);
const json = await response.json();
logger.debug('LnUrlWithdraw response: %o', json);
if (json.status === 'ERROR') {
throw new Error(json.reason || 'LnServiceError');
}
// Return invoice id to check status
return info.id;
} catch (error) {
logger.error('Error withdrawing from LnUrl service: %o', error);
throw new Error('ProblemWithdrawingFromLnUrlService');
}
},
},
LnUrlRequest: {
__resolveType(parent: RequestWithType) {
if (parent.tag === 'payRequest') {
return 'PayRequest';
}
if (parent.tag === 'withdrawRequest') {
return 'WithdrawRequest';
}
return 'Unknown';
},
},
};

View File

@ -0,0 +1,32 @@
import { gql } from '@apollo/client';
export const lnUrlTypes = gql`
type WithdrawRequest {
callback: String
k1: String
maxWithdrawable: String
defaultDescription: String
minWithdrawable: String
tag: String
}
type PayRequest {
callback: String
maxSendable: String
minSendable: String
metadata: String
commentAllowed: Int
tag: String
}
union LnUrlRequest = WithdrawRequest | PayRequest
type PaySuccess {
tag: String
description: String
url: String
message: String
ciphertext: String
iv: String
}
`;

View File

@ -91,6 +91,15 @@ export const queryTypes = gql`
export const mutationTypes = gql`
type Mutation {
lnUrlPay(callback: String!, amount: Int!, comment: String): PaySuccess!
lnUrlWithdraw(
callback: String!
amount: Int!
k1: String!
description: String
): String!
fetchLnUrl(url: String!): LnUrlRequest
lnUrl(type: String!, url: String!): String!
createBaseInvoice(amount: Int!): baseInvoiceType
createThunderPoints(
id: String!

View File

@ -99,3 +99,7 @@ export const verifyBackups = jest
export const verifyMessage = jest
.fn()
.mockReturnValue(Promise.resolve(res.verifyMessageResponse));
export const getPublicKey = jest
.fn()
.mockReturnValue(Promise.resolve(res.getPublicKeyResponse));

View File

@ -759,3 +759,7 @@ export const verifyBackupsResponse = {
export const verifyMessageResponse = {
signed_by: 'abc',
};
export const getPublicKeyResponse = {
public_key: 'public_key',
};

View File

@ -1,5 +1,27 @@
export type LndObject = {};
export type PayInvoiceType = {
fee: number;
fee_mtokens: string;
hops: [
{
channel: string;
channel_capacity: number;
fee_mtokens: string;
forward_mtokens: string;
timeout: number;
}
];
id: string;
is_confirmed: boolean;
is_outgoing: boolean;
mtokens: string;
secret: string;
safe_fee: number;
safe_tokens: number;
tokens: number;
};
export type ChannelType = {
id: string;
tokens: number;
@ -18,10 +40,25 @@ export type DecodedType = {
tokens: number;
};
export type GetPublicKeyType = {
public_key: string;
};
export type ClosedChannelsType = {
channels: [];
};
export type CreateInvoiceType = {
chain_address?: string;
created_at: string;
description: string;
id: string;
mtokens?: string;
request: string;
secret: string;
tokens?: number;
};
export type CloseChannelType = {
transaction_id: string;
transaction_vout: number;

View File

@ -36,6 +36,7 @@ const InputLine = styled(SingleLine)`
`;
type InputWithDecoProps = {
inputMaxWidth?: string;
title: string;
value?: string | number | null;
noInput?: boolean;
@ -60,6 +61,7 @@ export const InputWithDeco: React.FC<InputWithDecoProps> = ({
placeholder,
color,
noInput,
inputMaxWidth,
inputType = 'text',
inputCallback,
onKeyDown,
@ -91,7 +93,7 @@ export const InputWithDeco: React.FC<InputWithDecoProps> = ({
</InputTitleRow>
{!noInput && (
<Input
maxWidth={'500px'}
maxWidth={inputMaxWidth || '500px'}
placeholder={placeholder}
color={color}
withMargin={'0 0 0 8px'}

View File

@ -12,6 +12,18 @@
"name": "PaymentType"
}
]
},
{
"kind": "UNION",
"name": "LnUrlRequest",
"possibleTypes": [
{
"name": "WithdrawRequest"
},
{
"name": "PayRequest"
}
]
}
]
}

View File

@ -0,0 +1,169 @@
/* eslint-disable */
import * as Types from '../../types';
import { gql } from '@apollo/client';
import * as Apollo from '@apollo/client';
export type FetchLnUrlMutationVariables = Types.Exact<{
url: Types.Scalars['String'];
}>;
export type FetchLnUrlMutation = (
{ __typename?: 'Mutation' }
& { fetchLnUrl?: Types.Maybe<(
{ __typename?: 'WithdrawRequest' }
& Pick<Types.WithdrawRequest, 'callback' | 'k1' | 'maxWithdrawable' | 'defaultDescription' | 'minWithdrawable' | 'tag'>
) | (
{ __typename?: 'PayRequest' }
& Pick<Types.PayRequest, 'callback' | 'maxSendable' | 'minSendable' | 'metadata' | 'commentAllowed' | 'tag'>
)> }
);
export type PayLnUrlMutationVariables = Types.Exact<{
callback: Types.Scalars['String'];
amount: Types.Scalars['Int'];
comment?: Types.Maybe<Types.Scalars['String']>;
}>;
export type PayLnUrlMutation = (
{ __typename?: 'Mutation' }
& { lnUrlPay: (
{ __typename?: 'PaySuccess' }
& Pick<Types.PaySuccess, 'tag' | 'description' | 'url' | 'message' | 'ciphertext' | 'iv'>
) }
);
export type WithdrawLnUrlMutationVariables = Types.Exact<{
callback: Types.Scalars['String'];
amount: Types.Scalars['Int'];
k1: Types.Scalars['String'];
description?: Types.Maybe<Types.Scalars['String']>;
}>;
export type WithdrawLnUrlMutation = (
{ __typename?: 'Mutation' }
& Pick<Types.Mutation, 'lnUrlWithdraw'>
);
export const FetchLnUrlDocument = gql`
mutation FetchLnUrl($url: String!) {
fetchLnUrl(url: $url) {
... on WithdrawRequest {
callback
k1
maxWithdrawable
defaultDescription
minWithdrawable
tag
}
... on PayRequest {
callback
maxSendable
minSendable
metadata
commentAllowed
tag
}
}
}
`;
export type FetchLnUrlMutationFn = Apollo.MutationFunction<FetchLnUrlMutation, FetchLnUrlMutationVariables>;
/**
* __useFetchLnUrlMutation__
*
* To run a mutation, you first call `useFetchLnUrlMutation` within a React component and pass it any options that fit your needs.
* When your component renders, `useFetchLnUrlMutation` returns a tuple that includes:
* - A mutate function that you can call at any time to execute the mutation
* - An object with fields that represent the current status of the mutation's execution
*
* @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
*
* @example
* const [fetchLnUrlMutation, { data, loading, error }] = useFetchLnUrlMutation({
* variables: {
* url: // value for 'url'
* },
* });
*/
export function useFetchLnUrlMutation(baseOptions?: Apollo.MutationHookOptions<FetchLnUrlMutation, FetchLnUrlMutationVariables>) {
return Apollo.useMutation<FetchLnUrlMutation, FetchLnUrlMutationVariables>(FetchLnUrlDocument, baseOptions);
}
export type FetchLnUrlMutationHookResult = ReturnType<typeof useFetchLnUrlMutation>;
export type FetchLnUrlMutationResult = Apollo.MutationResult<FetchLnUrlMutation>;
export type FetchLnUrlMutationOptions = Apollo.BaseMutationOptions<FetchLnUrlMutation, FetchLnUrlMutationVariables>;
export const PayLnUrlDocument = gql`
mutation PayLnUrl($callback: String!, $amount: Int!, $comment: String) {
lnUrlPay(callback: $callback, amount: $amount, comment: $comment) {
tag
description
url
message
ciphertext
iv
}
}
`;
export type PayLnUrlMutationFn = Apollo.MutationFunction<PayLnUrlMutation, PayLnUrlMutationVariables>;
/**
* __usePayLnUrlMutation__
*
* To run a mutation, you first call `usePayLnUrlMutation` within a React component and pass it any options that fit your needs.
* When your component renders, `usePayLnUrlMutation` returns a tuple that includes:
* - A mutate function that you can call at any time to execute the mutation
* - An object with fields that represent the current status of the mutation's execution
*
* @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
*
* @example
* const [payLnUrlMutation, { data, loading, error }] = usePayLnUrlMutation({
* variables: {
* callback: // value for 'callback'
* amount: // value for 'amount'
* comment: // value for 'comment'
* },
* });
*/
export function usePayLnUrlMutation(baseOptions?: Apollo.MutationHookOptions<PayLnUrlMutation, PayLnUrlMutationVariables>) {
return Apollo.useMutation<PayLnUrlMutation, PayLnUrlMutationVariables>(PayLnUrlDocument, baseOptions);
}
export type PayLnUrlMutationHookResult = ReturnType<typeof usePayLnUrlMutation>;
export type PayLnUrlMutationResult = Apollo.MutationResult<PayLnUrlMutation>;
export type PayLnUrlMutationOptions = Apollo.BaseMutationOptions<PayLnUrlMutation, PayLnUrlMutationVariables>;
export const WithdrawLnUrlDocument = gql`
mutation WithdrawLnUrl($callback: String!, $amount: Int!, $k1: String!, $description: String) {
lnUrlWithdraw(callback: $callback, amount: $amount, k1: $k1, description: $description)
}
`;
export type WithdrawLnUrlMutationFn = Apollo.MutationFunction<WithdrawLnUrlMutation, WithdrawLnUrlMutationVariables>;
/**
* __useWithdrawLnUrlMutation__
*
* To run a mutation, you first call `useWithdrawLnUrlMutation` within a React component and pass it any options that fit your needs.
* When your component renders, `useWithdrawLnUrlMutation` returns a tuple that includes:
* - A mutate function that you can call at any time to execute the mutation
* - An object with fields that represent the current status of the mutation's execution
*
* @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
*
* @example
* const [withdrawLnUrlMutation, { data, loading, error }] = useWithdrawLnUrlMutation({
* variables: {
* callback: // value for 'callback'
* amount: // value for 'amount'
* k1: // value for 'k1'
* description: // value for 'description'
* },
* });
*/
export function useWithdrawLnUrlMutation(baseOptions?: Apollo.MutationHookOptions<WithdrawLnUrlMutation, WithdrawLnUrlMutationVariables>) {
return Apollo.useMutation<WithdrawLnUrlMutation, WithdrawLnUrlMutationVariables>(WithdrawLnUrlDocument, baseOptions);
}
export type WithdrawLnUrlMutationHookResult = ReturnType<typeof useWithdrawLnUrlMutation>;
export type WithdrawLnUrlMutationResult = Apollo.MutationResult<WithdrawLnUrlMutation>;
export type WithdrawLnUrlMutationOptions = Apollo.BaseMutationOptions<WithdrawLnUrlMutation, WithdrawLnUrlMutationVariables>;

View File

@ -0,0 +1,53 @@
import { gql } from '@apollo/client';
export const FETCH_LN_URL = gql`
mutation FetchLnUrl($url: String!) {
fetchLnUrl(url: $url) {
... on WithdrawRequest {
callback
k1
maxWithdrawable
defaultDescription
minWithdrawable
tag
}
... on PayRequest {
callback
maxSendable
minSendable
metadata
commentAllowed
tag
}
}
}
`;
export const PAY_LN_URL = gql`
mutation PayLnUrl($callback: String!, $amount: Int!, $comment: String) {
lnUrlPay(callback: $callback, amount: $amount, comment: $comment) {
tag
description
url
message
ciphertext
iv
}
}
`;
export const WITHDRAW_LN_URL = gql`
mutation WithdrawLnUrl(
$callback: String!
$amount: Int!
$k1: String!
$description: String
) {
lnUrlWithdraw(
callback: $callback
amount: $amount
k1: $k1
description: $description
)
}
`;

View File

@ -208,6 +208,10 @@ export type QueryGetSessionTokenArgs = {
export type Mutation = {
__typename?: 'Mutation';
lnUrlPay: PaySuccess;
lnUrlWithdraw: Scalars['String'];
fetchLnUrl?: Maybe<LnUrlRequest>;
lnUrl: Scalars['String'];
createBaseInvoice?: Maybe<BaseInvoiceType>;
createThunderPoints: Scalars['Boolean'];
closeChannel?: Maybe<CloseChannelType>;
@ -230,6 +234,32 @@ export type Mutation = {
};
export type MutationLnUrlPayArgs = {
callback: Scalars['String'];
amount: Scalars['Int'];
comment?: Maybe<Scalars['String']>;
};
export type MutationLnUrlWithdrawArgs = {
callback: Scalars['String'];
amount: Scalars['Int'];
k1: Scalars['String'];
description?: Maybe<Scalars['String']>;
};
export type MutationFetchLnUrlArgs = {
url: Scalars['String'];
};
export type MutationLnUrlArgs = {
type: Scalars['String'];
url: Scalars['String'];
};
export type MutationCreateBaseInvoiceArgs = {
amount: Scalars['Int'];
};
@ -965,3 +995,35 @@ export type BaseInvoiceType = {
id: Scalars['String'];
request: Scalars['String'];
};
export type WithdrawRequest = {
__typename?: 'WithdrawRequest';
callback?: Maybe<Scalars['String']>;
k1?: Maybe<Scalars['String']>;
maxWithdrawable?: Maybe<Scalars['String']>;
defaultDescription?: Maybe<Scalars['String']>;
minWithdrawable?: Maybe<Scalars['String']>;
tag?: Maybe<Scalars['String']>;
};
export type PayRequest = {
__typename?: 'PayRequest';
callback?: Maybe<Scalars['String']>;
maxSendable?: Maybe<Scalars['String']>;
minSendable?: Maybe<Scalars['String']>;
metadata?: Maybe<Scalars['String']>;
commentAllowed?: Maybe<Scalars['Int']>;
tag?: Maybe<Scalars['String']>;
};
export type LnUrlRequest = WithdrawRequest | PayRequest;
export type PaySuccess = {
__typename?: 'PaySuccess';
tag?: Maybe<Scalars['String']>;
description?: Maybe<Scalars['String']>;
url?: Maybe<Scalars['String']>;
message?: Maybe<Scalars['String']>;
ciphertext?: Maybe<Scalars['String']>;
iv?: Maybe<Scalars['String']>;
};

View File

@ -1,3 +1,5 @@
import bech32 from 'bech32';
export const getUrlParam = (
params: string | string[] | undefined
): string | null => {
@ -14,3 +16,10 @@ export const getUrlParam = (
return null;
};
export const decodeLnUrl = (url: string): string => {
const cleanUrl = url.toLowerCase().replace('lightning:', '');
const { words } = bech32.decode(cleanUrl, 500);
const bytes = bech32.fromWords(words);
return new String(Buffer.from(bytes)).toString();
};

View File

@ -1,6 +1,6 @@
import React, { useState } from 'react';
import styled from 'styled-components';
import { X, Layers, GitBranch } from 'react-feather';
import { X, Layers, GitBranch, Command } from 'react-feather';
import {
CardWithTitle,
SubTitle,
@ -17,6 +17,7 @@ import { DecodeCard } from './decode/Decode';
import { SupportCard } from './donate/DonateCard';
import { SupportBar } from './donate/DonateContent';
import { OpenChannel } from './openChannel';
import { LnUrlCard } from './lnurl';
const QuickCard = styled.div`
background: ${cardColor};
@ -59,6 +60,8 @@ export const QuickActions = () => {
return 'Decode a Lightning Request';
case 'open_channel':
return 'Open a Channel';
case 'ln_url':
return 'Use lnurl';
default:
return 'Quick Actions';
}
@ -70,6 +73,8 @@ export const QuickActions = () => {
return <SupportBar />;
case 'decode':
return <DecodeCard />;
case 'ln_url':
return <LnUrlCard />;
case 'open_channel':
return (
<Card>
@ -88,6 +93,10 @@ export const QuickActions = () => {
<Layers size={24} />
<QuickTitle>Decode</QuickTitle>
</QuickCard>
<QuickCard onClick={() => setOpenCard('ln_url')}>
<Command size={24} />
<QuickTitle>LNURL</QuickTitle>
</QuickCard>
</QuickRow>
);
}

View File

@ -0,0 +1,139 @@
import { FC, useState } from 'react';
import { PayRequest } from 'src/graphql/types';
import styled from 'styled-components';
import { Title } from 'src/components/typography/Styled';
import { Separation } from 'src/components/generic/Styled';
import { renderLine } from 'src/components/generic/helpers';
import { InputWithDeco } from 'src/components/input/InputWithDeco';
import { ColorButton } from 'src/components/buttons/colorButton/ColorButton';
import { usePayLnUrlMutation } from 'src/graphql/mutations/__generated__/lnUrl.generated';
import { Link } from 'src/components/link/Link';
import { toast } from 'react-toastify';
import { getErrorContent } from 'src/utils/error';
const ModalText = styled.div`
width: 100%;
text-align: center;
`;
const StyledLink = styled(ModalText)`
margin: 16px 0 32px;
font-size: 24px;
`;
type LnPayProps = {
request: PayRequest;
};
export const LnPay: FC<LnPayProps> = ({ request }) => {
const { minSendable, maxSendable, callback, commentAllowed } = request;
const min = Number(minSendable) / 1000 || 0;
const max = Number(maxSendable) / 1000 || 0;
const isSame = min === max;
const [amount, setAmount] = useState<number>(min);
const [comment, setComment] = useState<string>('');
const [payLnUrl, { data, loading }] = usePayLnUrlMutation({
onError: error => toast.error(getErrorContent(error)),
});
if (!callback) {
return <ModalText>Missing information from LN Service</ModalText>;
}
const callbackUrl = new URL(callback);
if (!loading && data?.lnUrlPay.tag) {
const { tag, url, description, message, ciphertext, iv } = data.lnUrlPay;
if (tag === 'url') {
return (
<>
<Title>Success</Title>
{(description || url) && <Separation />}
{description && <ModalText>{description}</ModalText>}
{url && (
<StyledLink>
<Link href={url}>{url}</Link>
</StyledLink>
)}
</>
);
}
if (tag === 'message') {
return (
<>
<Title>Success</Title>
{message && <Separation />}
{message && <ModalText>{message}</ModalText>}
</>
);
}
if (tag === 'aes') {
return (
<>
<Title>Success</Title>
{(description || ciphertext || iv) && <Separation />}
{description && <ModalText>{description}</ModalText>}
{renderLine('Ciphertext', ciphertext)}
{renderLine('IV', iv)}
</>
);
}
return <Title>Success</Title>;
}
return (
<>
<Title>Pay</Title>
<Separation />
<ModalText>{`Pay to ${callbackUrl.host}`}</ModalText>
<Separation />
{isSame && renderLine('Pay Amount (sats)', max)}
{!isSame && renderLine('Max Pay Amount (sats)', max)}
{!isSame && renderLine('Min Pay Amount (sats)', min)}
<Separation />
{!!commentAllowed && (
<InputWithDeco
inputMaxWidth={'300px'}
title={`Comment (Max ${commentAllowed} characters)`}
value={comment}
inputCallback={value => {
setComment(value.substring(0, commentAllowed));
}}
/>
)}
{!isSame && (
<InputWithDeco
inputMaxWidth={'300px'}
title={'Amount'}
amount={amount}
value={amount}
inputType={'number'}
inputCallback={value => {
if (min && max) {
setAmount(Math.min(max, Math.max(min, Number(value))));
} else if (min && !max) {
setAmount(Math.max(min, Number(value)));
} else if (!min && max) {
setAmount(Math.min(max, Number(value)));
} else {
setAmount(Number(value));
}
}}
/>
)}
<ColorButton
loading={loading}
disabled={loading}
fullWidth={true}
withMargin={'16px 0 0'}
onClick={() => payLnUrl({ variables: { callback, amount, comment } })}
>
{`Pay (${amount} sats)`}
</ColorButton>
</>
);
};

View File

@ -0,0 +1,180 @@
import { FC, useEffect, useState } from 'react';
import { WithdrawRequest } from 'src/graphql/types';
import styled from 'styled-components';
import { Title } from 'src/components/typography/Styled';
import { DarkSubTitle, Separation } from 'src/components/generic/Styled';
import { renderLine } from 'src/components/generic/helpers';
import { InputWithDeco } from 'src/components/input/InputWithDeco';
import { ColorButton } from 'src/components/buttons/colorButton/ColorButton';
import { useWithdrawLnUrlMutation } from 'src/graphql/mutations/__generated__/lnUrl.generated';
import { useGetInvoiceStatusChangeLazyQuery } from 'src/graphql/queries/__generated__/getInvoiceStatusChange.generated';
import { chartColors } from 'src/styles/Themes';
import { CheckCircle } from 'react-feather';
import { Link } from 'src/components/link/Link';
import { getErrorContent } from 'src/utils/error';
import { toast } from 'react-toastify';
import { Timer } from '../../account/createInvoice/Timer';
const Center = styled.div`
margin: 16px;
display: flex;
justify-content: center;
align-items: center;
`;
const ModalText = styled.div`
width: 100%;
text-align: center;
`;
type LnWithdrawProps = {
request: WithdrawRequest;
};
export const LnWithdraw: FC<LnWithdrawProps> = ({ request }) => {
const {
minWithdrawable,
maxWithdrawable,
callback,
defaultDescription,
k1,
} = request;
const min = Number(minWithdrawable) / 1000 || 0;
const max = Number(maxWithdrawable) / 1000 || 0;
const isSame = min === max;
const [invoiceStatus, setInvoiceStatus] = useState<string>('none');
const [amount, setAmount] = useState<number>(min);
const [description, setDescription] = useState<string>(
defaultDescription || ''
);
const [withdraw, { data, loading }] = useWithdrawLnUrlMutation({
onError: error => toast.error(getErrorContent(error)),
});
const [
checkStatus,
{ data: statusData, loading: statusLoading, error },
] = useGetInvoiceStatusChangeLazyQuery({
onError: error => toast.error(getErrorContent(error)),
});
useEffect(() => {
if (!loading && data?.lnUrlWithdraw) {
checkStatus({ variables: { id: data.lnUrlWithdraw } });
}
}, [loading, data, checkStatus]);
useEffect(() => {
if (statusLoading || !statusData?.getInvoiceStatusChange) return;
setInvoiceStatus(statusData.getInvoiceStatusChange);
}, [statusLoading, statusData]);
if (!callback) {
return <ModalText>Missing information from LN Service</ModalText>;
}
const callbackUrl = new URL(callback);
const renderContent = () => {
if (error) {
return (
<Center>
<DarkSubTitle>
Failed to check status of the withdrawal. Please check the status in
the
<Link to={'/transactions'}> Transactions </Link>
view
</DarkSubTitle>
</Center>
);
}
if (invoiceStatus === 'paid') {
return (
<Center>
<CheckCircle stroke={chartColors.green} size={32} />
<Title>Paid</Title>
</Center>
);
}
if (invoiceStatus === 'not_paid' || invoiceStatus === 'timeout') {
return (
<Center>
<Title>
Check the status of this invoice in the
<Link to={'/transactions'}> Transactions </Link>
view
</Title>
</Center>
);
}
if (statusLoading) {
return (
<>
<Timer initialMinute={1} initialSeconds={30} />
<div>hello</div>
</>
);
}
return (
<>
{isSame && renderLine('Withdraw Amount', max)}
{!isSame && renderLine('Max Withdraw Amount', max)}
{!isSame && renderLine('Min Withdraw Amount', min)}
<Separation />
<InputWithDeco
inputMaxWidth={'300px'}
title={'Description'}
value={description}
inputCallback={value => setDescription(value)}
/>
{!isSame && (
<InputWithDeco
inputMaxWidth={'300px'}
title={'Amount'}
amount={amount}
value={amount}
inputType={'number'}
inputCallback={value => {
if (min && max) {
setAmount(Math.min(max, Math.max(min, Number(value))));
} else if (min && !max) {
setAmount(Math.max(min, Number(value)));
} else if (!min && max) {
setAmount(Math.min(max, Number(value)));
} else {
setAmount(Number(value));
}
}}
/>
)}
<ColorButton
loading={loading || statusLoading}
disabled={loading || !k1 || statusLoading}
fullWidth={true}
withMargin={'16px 0 0'}
onClick={() =>
withdraw({
variables: { callback, amount, k1: k1 || '', description },
})
}
>
{`Withdraw (${amount} sats)`}
</ColorButton>
</>
);
};
return (
<>
<Title>Withdraw</Title>
<Separation />
<ModalText>{`Withdraw from ${callbackUrl.host}`}</ModalText>
<Separation />
{renderContent()}
</>
);
};

View File

@ -0,0 +1,66 @@
import { useState } from 'react';
import { toast } from 'react-toastify';
import { ColorButton } from 'src/components/buttons/colorButton/ColorButton';
import { Card } from 'src/components/generic/Styled';
import { InputWithDeco } from 'src/components/input/InputWithDeco';
import Modal from 'src/components/modal/ReactModal';
import { decodeLnUrl } from 'src/utils/url';
import { LnUrlModal } from './lnUrlModal';
export const LnUrlCard = () => {
const [lnurl, setLnUrl] = useState<string>('');
const [url, setUrl] = useState<string>('');
const [type, setType] = useState<string>('');
const [modalOpen, setModalOpen] = useState<boolean>(false);
const handleDecode = () => {
if (!lnurl) {
toast.warning('Please input a LNURL');
return;
}
try {
const urlString = decodeLnUrl(lnurl);
const url = new URL(urlString);
const tag = url.searchParams.get('tag') || '';
setUrl(urlString);
setType(tag);
if (url && tag !== 'login') {
setModalOpen(true);
}
if (tag === 'login') {
toast.warning('LnAuth is not available yet');
}
} catch (error) {
toast.error('Problem decoding LNURL');
}
};
return (
<>
<Card>
<InputWithDeco
value={lnurl}
placeholder={'LnPay or LnWithdraw URL'}
title={'LNURL'}
inputCallback={value => setLnUrl(value)}
onEnter={() => handleDecode()}
/>
<ColorButton
arrow={true}
fullWidth={true}
disabled={!lnurl}
withMargin={'16px 0 0'}
onClick={() => handleDecode()}
>
Confirm
</ColorButton>
</Card>
<Modal isOpen={modalOpen} closeCallback={() => setModalOpen(false)}>
<LnUrlModal url={url} type={type} />
</Modal>
</>
);
};

View File

@ -0,0 +1,62 @@
import { FC, useEffect } from 'react';
import { toast } from 'react-toastify';
import { ColorButton } from 'src/components/buttons/colorButton/ColorButton';
import { Separation } from 'src/components/generic/Styled';
import { LoadingCard } from 'src/components/loading/LoadingCard';
import { Title } from 'src/components/typography/Styled';
import { useFetchLnUrlMutation } from 'src/graphql/mutations/__generated__/lnUrl.generated';
import { getErrorContent } from 'src/utils/error';
import styled from 'styled-components';
import { LnPay } from './LnPay';
import { LnWithdraw } from './LnWithdraw';
const ModalText = styled.div`
width: 100%;
text-align: center;
`;
type lnUrlProps = {
url: string;
type?: string;
};
export const LnUrlModal: FC<lnUrlProps> = ({ url, type }) => {
const fullUrl = new URL(url);
const [fetchLnUrl, { data, loading }] = useFetchLnUrlMutation({
onError: error => toast.error(getErrorContent(error)),
});
useEffect(() => {
if (!type) {
fetchLnUrl({ variables: { url } });
}
}, [type, url, fetchLnUrl]);
if (!type && !data) {
return <LoadingCard noCard={true} />;
}
if (loading || !data) {
return <LoadingCard noCard={true} />;
}
if (data?.fetchLnUrl?.__typename === 'PayRequest') {
return <LnPay request={data.fetchLnUrl} />;
}
if (data?.fetchLnUrl?.__typename === 'WithdrawRequest') {
return <LnWithdraw request={data.fetchLnUrl} />;
}
return (
<>
<Title>Login</Title>
<Separation />
<ModalText>{`Login to ${fullUrl.host}`}</ModalText>;
<ColorButton fullWidth={true} withMargin={'32px 0 0'}>
Confirm
</ColorButton>
</>
);
};