From fd769cb177dfa19683bc456e1c065abb1a0042b5 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 16 Oct 2023 12:46:50 -0700 Subject: [PATCH] Add example UMA VASP. Fix lightspark-sdk fundNode variable reference (#284) * Add SendingVaspRequestCache and implement the first sending vasp request (#6840) GitOrigin-RevId: 2feef192ac499eb6f873219d1bee4c81c60aa19b * Implement payReq request for uma JS VASP (#6846) GitOrigin-RevId: 4e28858eb367509f5f6aede93d4d5ef175aad4cb * CI update lock file for PR * Finish the other sending VASP calls for JS demo VASP (#6857) GitOrigin-RevId: b11d94ab5ae2257d334c6193a193bdf89f30c18a * feat: add landing page, icons, and fix icons in Button GitOrigin-RevId: 46b04b13bfdf7bc02a013c97cba56d5908cc3322 * use html select for language selector GitOrigin-RevId: 43d6ecdb9be4446ac206ed67a0da2ad33253c600 * [umame-docs] fix navlink and collapsible styles, fonts, animations, in side and mobile navs GitOrigin-RevId: d6328a0b278102f1a26d0dda722f65b2c8d4e56e * [lightspark-sdk] Fix fund_node var ref (#6891) GitOrigin-RevId: eb773fca3cce6174a8d31f59224a7475b677bfb9 * Update from public js-sdk main branch (#6838) Update public `js` sources with the latest code from the [public repository](https://github.com/lightsparkdev/js-sdk) main branch. This typically happens when new versions of the SDK are released and version updates need to be synced. The PR should be merged as soon as possible to avoid updates to webdev overwriting the changes in the js-sdk develop branch. --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: github-actions[bot] Co-authored-by: Lightspark Eng Co-authored-by: Corey Martin GitOrigin-RevId: 626c59ad85156be2e05fda63c01bbac3420c7348 * CI update lock file for PR * Create fresh-toes-count.md --------- Co-authored-by: Jeremy Klein Co-authored-by: Lightspark Eng Co-authored-by: Brian Siao Tick Chong Co-authored-by: Corey Martin Co-authored-by: lightspark-ci-js-sdk[bot] <134011073+lightspark-ci-js-sdk[bot]@users.noreply.github.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: github-actions[bot] --- .changeset/fresh-toes-count.md | 5 + apps/examples/uma-vasp/src/ReceivingVasp.ts | 1 - apps/examples/uma-vasp/src/SendingVasp.ts | 357 +++++++++++++++++- .../uma-vasp/src/SendingVaspRequestCache.ts | 81 ++++ .../lightspark-sdk/src/graphql/FundNode.ts | 2 +- packages/ui/components/Button.tsx | 48 ++- packages/ui/components/Collapsible.tsx | 30 +- packages/ui/icons/AI.tsx | 26 ++ packages/ui/icons/JavaTwoTone.tsx | 19 +- packages/ui/icons/Selector.tsx | 33 ++ packages/ui/icons/Terminal.tsx | 26 ++ packages/ui/styles/colors.tsx | 9 + .../ui/styles/fonts/typography/Article.tsx | 33 +- .../ui/styles/fonts/typography/Display.tsx | 41 ++ .../ui/styles/fonts/typography/Headline.tsx | 2 - .../styles/fonts/typography/LabelStrong.tsx | 35 ++ packages/ui/styles/fonts/typography/Title.tsx | 33 ++ packages/ui/styles/fonts/typography/index.ts | 3 + packages/ui/styles/fonts/typographyTokens.ts | 14 +- 19 files changed, 731 insertions(+), 67 deletions(-) create mode 100644 .changeset/fresh-toes-count.md create mode 100644 apps/examples/uma-vasp/src/SendingVaspRequestCache.ts create mode 100644 packages/ui/icons/AI.tsx create mode 100644 packages/ui/icons/Selector.tsx create mode 100644 packages/ui/icons/Terminal.tsx create mode 100644 packages/ui/styles/fonts/typography/Display.tsx create mode 100644 packages/ui/styles/fonts/typography/LabelStrong.tsx create mode 100644 packages/ui/styles/fonts/typography/Title.tsx diff --git a/.changeset/fresh-toes-count.md b/.changeset/fresh-toes-count.md new file mode 100644 index 000000000..5a979dab7 --- /dev/null +++ b/.changeset/fresh-toes-count.md @@ -0,0 +1,5 @@ +--- +"@lightsparkdev/lightspark-sdk": patch +--- + +Fix fundNode variable reference diff --git a/apps/examples/uma-vasp/src/ReceivingVasp.ts b/apps/examples/uma-vasp/src/ReceivingVasp.ts index 9322a0eaa..9ddfc0b35 100644 --- a/apps/examples/uma-vasp/src/ReceivingVasp.ts +++ b/apps/examples/uma-vasp/src/ReceivingVasp.ts @@ -45,7 +45,6 @@ export default class ReceivingVasp { tag: "payRequest", }); } - // const lookup = await this.lookup(receiver); res.send("ok"); } diff --git a/apps/examples/uma-vasp/src/SendingVasp.ts b/apps/examples/uma-vasp/src/SendingVasp.ts index f6d5dc7e1..655fd208c 100644 --- a/apps/examples/uma-vasp/src/SendingVasp.ts +++ b/apps/examples/uma-vasp/src/SendingVasp.ts @@ -1,9 +1,23 @@ -import { LightsparkClient } from "@lightsparkdev/lightspark-sdk"; +import { convertCurrencyAmount, hexToBytes } from "@lightsparkdev/core"; +import { + CurrencyUnit, + InvoiceData, + LightsparkClient, + OutgoingPayment, + TransactionStatus, +} from "@lightsparkdev/lightspark-sdk"; import * as uma from "@uma-sdk/core"; import { Express, Request, Response } from "express"; +import settings from "../../settings.json" assert { type: "json" }; +import SendingVaspRequestCache, { + SendingVaspPayReqData, +} from "./SendingVaspRequestCache.js"; import UmaConfig from "./UmaConfig.js"; export default class SendingVasp { + private readonly requestCache: SendingVaspRequestCache = + new SendingVaspRequestCache(); + constructor( private readonly config: UmaConfig, private readonly lightsparkClient: LightsparkClient, @@ -25,19 +39,348 @@ export default class SendingVasp { private async handleClientUmaLookup(req: Request, res: Response) { const receiver = req.params.receiver; - // const lookup = await this.lookup(receiver); - res.send("ok"); + if (!receiver) { + res.status(400).send("Missing receiver"); + return; + } + + const [receiverId, receivingVaspDomain] = receiver.split("@"); + if (!receiverId || !receivingVaspDomain) { + res.status(400).send("Invalid receiver"); + return; + } + + const lnrulpRequestUrl = await uma.getSignedLnurlpRequestUrl({ + isSubjectToTravelRule: true, + receiverAddress: receiver, + signingPrivateKey: this.config.umaSigningPrivKey(), + senderVaspDomain: req.hostname, // TODO: Might need to include the port here. + }); + + let response: globalThis.Response; + try { + response = await fetch(lnrulpRequestUrl); + } catch (e) { + res.status(424).send("Error fetching Lnurlp request."); + return; + } + + // TODO: Handle versioning via the 412 response. + + if (!response.ok) { + res.status(424).send(`Error fetching Lnurlp request. ${response.status}`); + return; + } + + let lnurlpResponse: uma.LnurlpResponse; + try { + lnurlpResponse = uma.parseLnurlpResponse(await response.text()); + } catch (e) { + console.error("Error parsing lnurlp response.", e); + res.status(424).send("Error parsing Lnurlp response."); + return; + } + + let pubKeys = await this.fetchPubKeysOrFail(receivingVaspDomain, res); + if (!pubKeys) return; + + try { + const isSignatureValid = await uma.verifyUmaLnurlpResponseSignature( + lnurlpResponse, + hexToBytes(pubKeys.signingPubKey), + ); + if (!isSignatureValid) { + res.status(424).send("Invalid UMA response signature."); + return; + } + } catch (e) { + console.error("Error verifying UMA response signature.", e); + res.status(424).send("Error verifying UMA response signature."); + return; + } + + const callbackUuid = this.requestCache.saveLnurlpResponseData( + lnurlpResponse, + receiverId, + receivingVaspDomain, + ); + + res.send({ + currencies: lnurlpResponse.currencies, + minSendableSats: lnurlpResponse.minSendable, + maxSendableSats: lnurlpResponse.maxSendable, + callbackUuid: callbackUuid, + // You might not actually send this to a client in practice. + receiverKycStatus: lnurlpResponse.compliance.kycStatus, + }); } private async handleClientUmaPayreq(req: Request, res: Response) { const callbackUuid = req.params.callbackUuid; - // const payreq = await this.payreq(callbackUuid); - res.send("ok"); + if (!callbackUuid) { + res.status(400).send("Missing callbackUuid"); + return; + } + + const initialRequestData = + this.requestCache.getLnurlpResponseData(callbackUuid); + if (!initialRequestData) { + res.status(400).send("callbackUuid not found"); + return; + } + + const amountStr = req.query.amount; + if (!amountStr || typeof amountStr !== "string") { + res.status(400).send("Missing amount"); + return; + } + const amount = parseInt(amountStr); + if (isNaN(amount)) { + res.status(400).send("Invalid amount"); + return; + } + + const currencyCode = req.query.currencyCode; + if (!currencyCode || typeof currencyCode !== "string") { + res.status(400).send("Missing currencyCode"); + return; + } + const currencyValid = initialRequestData.lnurlpResponse.currencies.some( + (c) => c.code === currencyCode, + ); + if (!currencyValid) { + res.status(400).send("Currency code not supported"); + return; + } + + let pubKeys = await this.fetchPubKeysOrFail( + initialRequestData.receivingVaspDomain, + res, + ); + if (!pubKeys) return; + + const payerProfile = this.getPayerProfile( + initialRequestData.lnurlpResponse.requiredPayerData, + ); + const trInfo = + '["message": "Here is some fake travel rule info. It is up to you to actually implement this if needed."]'; + // In practice this should be loaded from your node: + const payerUtxos: string[] = []; + const utxoCallback = this.getUtxoCallback(req, "1234abcd"); + + let payReq: uma.PayRequest; + try { + payReq = await uma.getPayRequest({ + receiverEncryptionPubKey: hexToBytes(pubKeys.encryptionPubKey), + sendingVaspPrivateKey: this.config.umaSigningPrivKey(), + currencyCode, + amount, + payerIdentifier: payerProfile.identifier, + payerKycStatus: uma.KycStatus.Verified, + utxoCallback, + trInfo, + payerUtxos, + payerName: payerProfile.name, + payerEmail: payerProfile.email, + }); + } catch (e) { + console.error("Error generating payreq.", e); + res.status(500).send("Error generating payreq."); + return; + } + + let response: globalThis.Response; + try { + response = await fetch(initialRequestData.lnurlpResponse.callback, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(payReq), + }); + } catch (e) { + res.status(500).send("Error sending payreq."); + return; + } + + if (!response.ok) { + res.status(424).send(`Payreq failed. ${response.status}`); + return; + } + + let payResponse: uma.PayReqResponse; + try { + payResponse = await uma.parsePayReqResponse(await response.text()); + } catch (e) { + console.error("Error parsing payreq response.", e); + res.status(424).send("Error parsing payreq response."); + return; + } + + // This is where you'd pre-screen the UTXOs from payResponse.compliance.utxos. + + let invoice: InvoiceData; + try { + invoice = await this.lightsparkClient.decodeInvoice( + payResponse.encodedInvoice, + ); + } catch (e) { + console.error("Error decoding invoice.", e); + res.status(500).send("Error decoding invoice."); + return; + } + + const newCallbackUuid = this.requestCache.savePayReqData( + payResponse.encodedInvoice, + utxoCallback, + invoice, + ); + + res.send({ + callbackUuid: newCallbackUuid, + encodedInvoice: payResponse.encodedInvoice, + amount: invoice.amount, + conversionRate: payResponse.paymentInfo.multiplier, + exchangeFeesMillisatoshi: + payResponse.paymentInfo.exchangeFeesMillisatoshi, + currencyCode: payResponse.paymentInfo.currencyCode, + }); + } + + private async fetchPubKeysOrFail(receivingVaspDomain: string, res: Response) { + try { + return await uma.fetchPublicKeyForVasp({ + cache: this.pubKeyCache, + vaspDomain: receivingVaspDomain, + }); + } catch (e) { + console.error("Error fetching public key.", e); + res.status(424).send("Error fetching public key."); + } } private async handleClientSendPayment(req: Request, res: Response) { const callbackUuid = req.params.callbackUuid; - // const payment = await this.sendPayment(callbackUuid); - res.send("ok"); + if (!callbackUuid) { + res.status(400).send("Missing callbackUuid"); + return; + } + + const payReqData = this.requestCache.getPayReqData(callbackUuid); + if (!payReqData) { + res.status(400).send("callbackUuid not found"); + return; + } + + if (new Date(payReqData.invoiceData.expiresAt) < new Date()) { + res.status(400).send("Invoice expired"); + return; + } + + if (payReqData.invoiceData.amount.originalValue <= 0) { + res + .status(400) + .send("Invoice amount invalid. Uma requires positive amounts."); + return; + } + + let payment: OutgoingPayment; + try { + const paymentResult = await this.lightsparkClient.payUmaInvoice( + this.config.nodeID, + payReqData.encodedInvoice, + /* maxeesMsats */ 1_000_000, + ); + if (!paymentResult) { + throw new Error("Payment request failed."); + } + payment = await this.waitForPaymentCompletion(paymentResult); + } catch (e) { + console.error("Error paying invoice.", e); + res.status(500).send("Error paying invoice."); + return; + } + + await this.sendPostTransactionCallback(payment, payReqData); + + res.send({ + paymentId: payment.id, + didSucceed: payment.status === TransactionStatus.SUCCESS, + }); + } + + /** + * NOTE: In a real application, you'd want to use the authentication context to pull out this information. It's not + * actually always Alice sending the money ;-). + */ + private getPayerProfile(requiredPayerData: uma.PayerDataOptions) { + const port = process.env.PORT || settings.umaVasp.port; + return { + name: requiredPayerData.nameRequired ? "Alice FakeName" : undefined, + email: requiredPayerData.emailRequired ? "alice@vasp1.com" : undefined, + // Note: This is making an assumption that this is running on localhost. We should make it configurable. + identifier: `$alice@localhost:${port}`, + }; + } + + private getUtxoCallback(req: Request, txId: string): string { + const protocol = req.protocol; + const host = req.hostname; + const path = `/api/uma/utxoCallback?txId=${txId}`; + return `${protocol}://${host}${path}`; + } + + private async waitForPaymentCompletion( + paymentResult: OutgoingPayment, + retryNum = 0, + ): Promise { + if (paymentResult.status === TransactionStatus.SUCCESS) { + return paymentResult; + } + + const payment = await this.lightsparkClient.executeRawQuery( + OutgoingPayment.getOutgoingPaymentQuery(paymentResult.id), + ); + if (!payment) { + throw new Error("Payment not found."); + } + + if (payment.status !== TransactionStatus.PENDING) { + return payment; + } + + const maxRetries = 40; + if (retryNum >= maxRetries) { + throw new Error("Payment timed out."); + } + + await new Promise((resolve) => setTimeout(resolve, 250)); + return this.waitForPaymentCompletion(payment); + } + + private async sendPostTransactionCallback( + payment: OutgoingPayment, + payReqData: SendingVaspPayReqData, + ) { + const utxos = + payment.umaPostTransactionData?.map((d) => { + d.utxo, convertCurrencyAmount(d.amount, CurrencyUnit.MILLISATOSHI); + }) ?? []; + try { + const postTxResponse = await fetch(payReqData.utxoCallback, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ utxos }), + }); + if (!postTxResponse.ok) { + console.error( + `Error sending post transaction callback. ${postTxResponse.status}`, + ); + } + } catch (e) { + console.error("Error sending post transaction callback.", e); + } } } diff --git a/apps/examples/uma-vasp/src/SendingVaspRequestCache.ts b/apps/examples/uma-vasp/src/SendingVaspRequestCache.ts new file mode 100644 index 000000000..a21cf3daf --- /dev/null +++ b/apps/examples/uma-vasp/src/SendingVaspRequestCache.ts @@ -0,0 +1,81 @@ +import { InvoiceData } from "@lightsparkdev/lightspark-sdk"; +import { LnurlpResponse } from "@uma-sdk/core"; +import { v4 as uuidv4 } from "uuid"; + +/** + * A simple in-memory cache for data that needs to be remembered between calls to VASP1. In practice, this would be + * stored in a database or other persistent storage. + */ +export default class SendingVaspRequestCache { + /** + * This is a map of the UMA request UUID to the LnurlpResponse from that initial Lnurlp request. + * This is used to cache the LnurlpResponse so that we can use it to generate the UMA payreq without the client + * having to make another Lnurlp request or remember lots of details. + * NOTE: In production, this should be stored in a database or other persistent storage. + */ + private lnurlpRequestCache: Map = + new Map(); + + /** + * This is a map of the UMA request UUID to the payreq data that we generated for that request. + * This is used to cache the payreq data so that we can pay the invoice when the user confirms + * NOTE: In production, this should be stored in a database or other persistent storage. + */ + private payReqCache: Map = new Map(); + + public getLnurlpResponseData( + uuid: string, + ): SendingVaspInitialRequestData | undefined { + return this.lnurlpRequestCache.get(uuid); + } + + public getPayReqData(uuid: string): SendingVaspPayReqData | undefined { + return this.payReqCache.get(uuid); + } + + public saveLnurlpResponseData( + lnurlpResponse: LnurlpResponse, + receiverId: string, + receivingVaspDomain: string, + ): string { + const uuid = uuidv4(); + this.lnurlpRequestCache.set(uuid, { + lnurlpResponse, + receiverId, + receivingVaspDomain, + }); + return uuid; + } + + public savePayReqData( + encodedInvoice: string, + utxoCallback: string, + invoiceData: InvoiceData, + ): string { + const uuid = uuidv4(); + this.payReqCache.set(uuid, { + encodedInvoice, + utxoCallback, + invoiceData, + }); + return uuid; + } +} + +/** + * This is the data that we cache for the initial Lnurlp request. + */ +interface SendingVaspInitialRequestData { + lnurlpResponse: LnurlpResponse; + receiverId: string; + receivingVaspDomain: string; +} + +/** + * This is the data that we cache for the payreq request. + */ +export interface SendingVaspPayReqData { + encodedInvoice: string; + utxoCallback: string; + invoiceData: InvoiceData; +} diff --git a/packages/lightspark-sdk/src/graphql/FundNode.ts b/packages/lightspark-sdk/src/graphql/FundNode.ts index 857a23789..e19185148 100644 --- a/packages/lightspark-sdk/src/graphql/FundNode.ts +++ b/packages/lightspark-sdk/src/graphql/FundNode.ts @@ -7,7 +7,7 @@ export const FundNode = ` $node_id: ID!, $amountSats: Long ) { - fund_node(input: { node_id: $node_id, amount_sats: $amountMsats }) { + fund_node(input: { node_id: $node_id, amount_sats: $amountSats }) { amount { ...CurrencyAmountFragment } diff --git a/packages/ui/components/Button.tsx b/packages/ui/components/Button.tsx index dd9f0afaf..013fa4c2c 100644 --- a/packages/ui/components/Button.tsx +++ b/packages/ui/components/Button.tsx @@ -14,6 +14,8 @@ import { UnstyledButton } from "./UnstyledButton"; const ButtonSizes = ["sm", "md", "lg"] as const; type ButtonSize = (typeof ButtonSizes)[number]; +type IconSide = "left" | "right"; + export type ButtonProps = { backgroundColor?: string; color?: string; @@ -27,6 +29,7 @@ export type ButtonProps = { ghost?: boolean | undefined; size?: ButtonSize; icon?: string; + iconSide?: IconSide; loading?: boolean | undefined; onClick?: (() => void) | undefined; mt?: number; @@ -49,6 +52,7 @@ type PrimaryProps = { type PaddingProps = { size: ButtonSize; + iconSide?: IconSide | undefined; iconWidth?: number; text?: string | undefined; ghost?: boolean | undefined; @@ -94,19 +98,19 @@ function getBackgroundColor({ return themeOr(colors.white, theme.c1Neutral)({ theme }); } -function getPadding({ iconWidth, size, text, ghost }: PaddingProps) { - if (ghost) { - return "0"; - } - - const paddingForText = text ? 6 : 0; - return size === "lg" - ? `14px ${hPaddingPx}px 14px ${ - hPaddingPx + (iconWidth ? iconWidth + paddingForText : 0) - }px` +function getPadding({ iconWidth, size, text, ghost, iconSide }: PaddingProps) { + const paddingY = ghost ? 0 : size === "lg" ? 14 : size === "md" ? 9 : 6; + const paddingX = ghost + ? 0 + : size === "lg" + ? hPaddingPx : size === "md" - ? "9px 18px" - : "6px 16px"; + ? 18 + : 16; + const paddingForIcon = iconWidth ? iconWidth : 0; + return `${paddingY}px ${ + paddingX + (iconSide === "right" ? paddingForIcon : 0) + }px ${paddingY}px ${paddingX + (iconSide === "left" ? paddingForIcon : 0)}px`; } function getBorder({ ghost }: BorderProps) { @@ -145,6 +149,7 @@ export function Button({ toParams, onClick, icon, + iconSide = "left", loading = false, fullWidth = false, disabled = false, @@ -161,13 +166,13 @@ export function Button({ let currentIcon = null; if (loading) { currentIcon = ( - + ); } else if (icon) { currentIcon = ( - + ); @@ -181,7 +186,7 @@ export function Button({ justifyContent: "center", }} > - {currentIcon} + {iconSide === "left" && currentIcon}
({ > {text}
+ {iconSide === "right" && currentIcon} ); @@ -203,6 +209,7 @@ export function Button({ ghost, fullWidth, blue, + iconSide, iconWidth: currentIcon ? iconSize + iconMarginRight : 0, isLoading: loading, disabled: disabled || loading, @@ -246,6 +253,7 @@ type StyledButtonProps = { newTab: boolean; text?: string | undefined; zIndex?: number | undefined; + iconSide?: IconSide | undefined; }; const hPaddingPx = 24; @@ -263,6 +271,7 @@ const buttonStyle = ({ blue, text, zIndex, + iconSide, }: StyledButtonProps & { theme: Theme }) => css` display: inline-flex; opacity: ${disabled && !isLoading ? 0.2 : 1}; @@ -279,7 +288,7 @@ const buttonStyle = ({ outline: ${getFocusOutline({ theme })}; } - ${fullWidth && "width: 100%;"} + width: ${fullWidth ? "100%" : "fit-content"}; & > * { width: 100%; @@ -300,18 +309,21 @@ const buttonStyle = ({ blue, })}; border-radius: 32px; - padding: ${getPadding({ size, iconWidth, text, ghost })}; + padding: ${getPadding({ size, iconWidth, text, ghost, iconSide })}; color: ${getTextColor({ color, theme, primary, blue })}; } `; interface ButtonIconProps { ghost?: boolean | undefined; + iconSide?: IconSide | undefined; + text?: string | undefined; } const ButtonIcon = styled.div` position: absolute; - ${(props) => (props.ghost ? "" : `left: ${hPaddingPx}px;`)} + ${(props) => + `${props.iconSide}: ${props.ghost && props.text ? 0 : hPaddingPx}px;`} `; export const StyledButton = styled(UnstyledButton)` diff --git a/packages/ui/components/Collapsible.tsx b/packages/ui/components/Collapsible.tsx index 4559e7e3b..23364fbf9 100644 --- a/packages/ui/components/Collapsible.tsx +++ b/packages/ui/components/Collapsible.tsx @@ -2,13 +2,9 @@ import styled from "@emotion/styled"; import { useEffect, useState } from "react"; import { Icon } from "."; -interface TextStyles { - bold?: boolean; -} type CollapsibleProps = { children: React.ReactNode; className?: string; - textStyles?: TextStyles; text?: string; open?: boolean | undefined; handleToggle?: (open: boolean) => void | undefined; @@ -19,7 +15,6 @@ type CollapsibleProps = { export function Collapsible({ children, className, - textStyles, text, open, handleToggle, @@ -42,9 +37,9 @@ export function Collapsible({ const iconName = hamburger ? (isOpen ? "Close" : "StackedLines") : "Down"; return ( - +