diff --git a/i18n/locales/en/account.json b/i18n/locales/en/account.json index d39f91e3a..d325d16ea 100644 --- a/i18n/locales/en/account.json +++ b/i18n/locales/en/account.json @@ -326,7 +326,7 @@ }, "tooltip": { "multi-sig-account": "Multi-Signature Account", - "stellarguard": "StellarGuard Protection" + "security-service": "{{service}} Protection" }, "placeholder": "Account nameā€¦", "testnet": "Testnet" diff --git a/i18n/locales/en/app.json b/i18n/locales/en/app.json index 686e44532..5cc469d67 100644 --- a/i18n/locales/en/app.json +++ b/i18n/locales/en/app.json @@ -5,8 +5,8 @@ }, "badges": { "tooltip": { - "stellar-guard": "StellarGuard Protection", - "multi-sig": "Multi-Signature Account" + "multi-sig": "Multi-Signature Account", + "security-service": "{{service}} Protection" } } }, diff --git a/i18n/locales/en/generic.json b/i18n/locales/en/generic.json index 25905fc79..dfadc9534 100644 --- a/i18n/locales/en/generic.json +++ b/i18n/locales/en/generic.json @@ -31,6 +31,7 @@ "stellar-address-not-found-error": "Stellar address not found: {{address}}", "stellar-address-request-failed-error": "Stellar address resolution of {{address}} failed.", "submission-failed-error": "Submitting transaction to {{endpoint}} failed with status {{status}}: {{message}}", + "testnet-endpoint-not-available-error": "{{service}} does not provide a testnet endpoint.", "unexpected-action-error": "Unexpected action: {{action}}", "unexpected-state-error": "Encountered unexpected state: {{state}}", "unexpected-response-type-error": "Unexpected response type: {{type}} / ${dataType}", diff --git a/src/Account/components/AccountTitle.tsx b/src/Account/components/AccountTitle.tsx index 739fb5e13..31560f1d8 100644 --- a/src/Account/components/AccountTitle.tsx +++ b/src/Account/components/AccountTitle.tsx @@ -13,9 +13,8 @@ import VerifiedUserIcon from "@material-ui/icons/VerifiedUser" import { Account } from "~App/contexts/accounts" import { useLiveAccountData } from "~Generic/hooks/stellar-subscriptions" import { useIsMobile, useRouter } from "~Generic/hooks/userinterface" -import { containsStellarGuardAsSigner } from "~Generic/lib/stellar-guard" +import { containsThirdPartySigner, ThirdPartySecurityService } from "~Generic/lib/third-party-security" import { primaryBackgroundColor } from "~App/theme" -import StellarGuardIcon from "~Icons/components/StellarGuard" import { HorizontalLayout } from "~Layout/components/Box" import MainTitle from "~Generic/components/MainTitle" @@ -56,7 +55,7 @@ function TestnetBadge(props: { style?: React.CSSProperties }) { } interface StaticBadgesProps { - multisig: "generic" | "stellar-guard" | undefined + multisig: "generic" | ThirdPartySecurityService | undefined password: boolean testnet: boolean } @@ -73,10 +72,14 @@ export const StaticBadges = React.memo(function StaticBadges(props: StaticBadges ) - } else if (props.multisig === "stellar-guard") { + } else if (props.multisig) { return ( - - + + {props.multisig.icon({ style: { fontSize: "80%", marginRight: 8 } })} ) } else { @@ -95,19 +98,10 @@ interface BadgesProps { export const Badges = React.memo(function Badges(props: BadgesProps) { const accountData = useLiveAccountData(props.account.publicKey, props.account.testnet) - return ( - 1 - ? containsStellarGuardAsSigner(accountData.signers) - ? "stellar-guard" - : "generic" - : undefined - } - password={props.account.requiresPassword} - testnet={props.account.testnet} - /> - ) + const securityService = containsThirdPartySigner(accountData.signers) + const multisig = accountData.signers.length > 1 ? (securityService ? securityService : "generic") : undefined + + return }) const useTitleTextfieldStyles = makeStyles({ diff --git a/src/App/components/AccountList.tsx b/src/App/components/AccountList.tsx index 488299abf..1130661a9 100644 --- a/src/App/components/AccountList.tsx +++ b/src/App/components/AccountList.tsx @@ -14,10 +14,9 @@ import { Account } from "../contexts/accounts" import { SignatureDelegationContext } from "../contexts/signatureDelegation" import { useLiveAccountData } from "~Generic/hooks/stellar-subscriptions" import { useRouter } from "~Generic/hooks/userinterface" -import { containsStellarGuardAsSigner } from "~Generic/lib/stellar-guard" +import { containsThirdPartySigner } from "~Generic/lib/third-party-security" import { SignatureRequest } from "~Generic/lib/multisig-service" import InlineLoader from "~Generic/components/InlineLoader" -import StellarGuardIcon from "~Icons/components/StellarGuard" import { Box, HorizontalLayout, VerticalLayout } from "~Layout/components/Box" import * as routes from "../routes" @@ -71,9 +70,16 @@ const StyledBadge = (props: BadgeProps) => { function Badges(props: { account: Account }) { const { t } = useTranslation() const accountData = useLiveAccountData(props.account.publicKey, props.account.testnet) - const multiSigIcon = containsStellarGuardAsSigner(accountData.signers) ? ( - - + + const securityService = containsThirdPartySigner(accountData.signers) + + const multiSigIcon = securityService ? ( + + {securityService.icon({ style: { marginTop: 6 } })} ) : ( diff --git a/src/Generic/lib/stellar-guard.ts b/src/Generic/lib/stellar-guard.ts deleted file mode 100644 index 8b5afbcd2..000000000 --- a/src/Generic/lib/stellar-guard.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { Server, Transaction, Horizon } from "stellar-sdk" -import { CustomError } from "./errors" - -const STELLARGUARD_TRANSACTION_ENDPOINT_MAINNET = "https://stellarguard.me/api/transactions" -const STELLARGUARD_TRANSACTION_ENDPOINT_TESTNET = "https://test.stellarguard.me/api/transactions" -const STELLARGUARD_PUBLIC_KEY = "GCVHEKSRASJBD6O2Z532LWH4N2ZLCBVDLLTLKSYCSMBLOYTNMEEGUARD" - -export async function isStellarGuardProtected(horizon: Server, accountPubKey: string) { - const account = await horizon.loadAccount(accountPubKey) - return account.signers.some(signer => signer.key === STELLARGUARD_PUBLIC_KEY) -} - -export function containsStellarGuardAsSigner(signers: Horizon.AccountSigner[]) { - return signers.some(signer => signer.key === STELLARGUARD_PUBLIC_KEY) -} - -export async function submitTransactionToStellarGuard(signedTransaction: Transaction, testnet: boolean) { - const signedTransactionXDR = signedTransaction - .toEnvelope() - .toXDR() - .toString("base64") - - const body = { xdr: signedTransactionXDR } - - const endpoint = testnet ? STELLARGUARD_TRANSACTION_ENDPOINT_TESTNET : STELLARGUARD_TRANSACTION_ENDPOINT_MAINNET - - const response = await fetch(endpoint, { - method: "POST", - body: JSON.stringify(body), - headers: { - "Content-Type": "application/json" - } - }) - - if (!response.ok) { - const contentType = response.headers.get("Content-Type") - const responseBodyObject = contentType && contentType.startsWith("application/json") ? await response.json() : null - - const message = - responseBodyObject && responseBodyObject.message ? responseBodyObject.message : await response.text() - throw CustomError( - "SubmissionFailedError", - `Submitting transaction to StellarGuard failed with status ${response.status}: ${message}`, - { - endpoint: "Stellarguard", - message, - status: String(response.status) - } - ) - } - - return response -} diff --git a/src/Generic/lib/third-party-security.ts b/src/Generic/lib/third-party-security.ts new file mode 100644 index 000000000..bd46e8e17 --- /dev/null +++ b/src/Generic/lib/third-party-security.ts @@ -0,0 +1,97 @@ +import { Server, Transaction, Horizon } from "stellar-sdk" +import { CustomError } from "./errors" +import StellarGuardIcon from "~Icons/components/StellarGuard" +import LobstrVaultIcon from "~Icons/components/LobstrVault" + +export interface ThirdPartySecurityService { + endpoints: { + testnet?: string + mainnet: string + } + icon: (props: { style: React.CSSProperties }) => JSX.Element + name: string + publicKey: string +} + +const services: ThirdPartySecurityService[] = [ + { + endpoints: { + mainnet: "https://stellarguard.me/api/transactions", + testnet: "https://test.stellarguard.me/api/transactions" + }, + icon: StellarGuardIcon, + name: "StellarGuard", + publicKey: "GCVHEKSRASJBD6O2Z532LWH4N2ZLCBVDLLTLKSYCSMBLOYTNMEEGUARD" + }, + { + endpoints: { + mainnet: "https://vault.lobstr.co/api/transactions/" + }, + icon: LobstrVaultIcon, + name: "LOBSTR Vault", + publicKey: "GA2T6GR7VXXXBETTERSAFETHANSORRYXXXPROTECTEDBYLOBSTRVAULT" + } +] + +export async function isThirdPartyProtected(horizon: Server, accountPubKey: string) { + const account = await horizon.loadAccount(accountPubKey) + const signerKeys = account.signers.map(signer => signer.key) + + const enabledService = services.find(service => signerKeys.includes(service.publicKey)) + return enabledService +} + +export function containsThirdPartySigner(signers: Horizon.AccountSigner[]) { + const signerKeys = signers.map(signer => signer.key) + + const enabledService = services.find(service => signerKeys.includes(service.publicKey)) + return enabledService +} + +export async function submitTransactionToThirdPartyService( + signedTransaction: Transaction, + service: ThirdPartySecurityService, + testnet: boolean +) { + const signedTransactionXDR = signedTransaction + .toEnvelope() + .toXDR() + .toString("base64") + + const body = { xdr: signedTransactionXDR } + + if (testnet && !service.endpoints.testnet) { + throw CustomError("TestnetEndpointNotAvailableError", `${service.name} does not provide a testnet endpoint.`, { + service: service.name + }) + } + + const endpoint = testnet ? service.endpoints.testnet! : service.endpoints.mainnet + + const response = await fetch(endpoint, { + method: "POST", + body: JSON.stringify(body), + headers: { + "Content-Type": "application/json" + } + }) + + if (!response.ok) { + const contentType = response.headers.get("Content-Type") + const responseBodyObject = contentType && contentType.startsWith("application/json") ? await response.json() : null + + const message = + responseBodyObject && responseBodyObject.message ? responseBodyObject.message : await response.text() + throw CustomError( + "SubmissionFailedError", + `Submitting transaction to ${service.name} failed with status ${response.status}: ${message}`, + { + endpoint: service.name, + message, + status: String(response.status) + } + ) + } + + return response +} diff --git a/src/Icons/components/LobstrVault.tsx b/src/Icons/components/LobstrVault.tsx new file mode 100644 index 000000000..e49d19f9a --- /dev/null +++ b/src/Icons/components/LobstrVault.tsx @@ -0,0 +1,70 @@ +import * as React from "react" +import SvgIcon from "@material-ui/core/SvgIcon" + +function LobstrVaultIcon(props: { + className?: string + onClick?: () => void + role?: string + style?: React.CSSProperties +}) { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + ) +} + +export default LobstrVaultIcon diff --git a/src/Transaction/components/SubmissionProgress.tsx b/src/Transaction/components/SubmissionProgress.tsx index be91c5da7..6ffd61796 100644 --- a/src/Transaction/components/SubmissionProgress.tsx +++ b/src/Transaction/components/SubmissionProgress.tsx @@ -30,13 +30,13 @@ function Heading(props: { children: React.ReactNode }) { export enum SubmissionType { default, multisig, - stellarguard + thirdParty } const successMessages: { [type: number]: string } = { [SubmissionType.default]: "Successful", [SubmissionType.multisig]: "Waiting for missing signatures", - [SubmissionType.stellarguard]: "Waiting for StellarGuard authorization" + [SubmissionType.thirdParty]: "Waiting for authorization of third-party service" } interface SubmissionProgressProps { diff --git a/src/Transaction/components/TransactionSender.tsx b/src/Transaction/components/TransactionSender.tsx index 5a6f9aa95..e4763e7c6 100644 --- a/src/Transaction/components/TransactionSender.tsx +++ b/src/Transaction/components/TransactionSender.tsx @@ -17,7 +17,11 @@ import { } from "~Generic/lib/multisig-service" import { networkPassphrases } from "~Generic/lib/stellar" import { hasSigned, requiresRemoteSignatures, signTransaction } from "~Generic/lib/transaction" -import { isStellarGuardProtected, submitTransactionToStellarGuard } from "~Generic/lib/stellar-guard" +import { + isThirdPartyProtected, + submitTransactionToThirdPartyService, + ThirdPartySecurityService +} from "~Generic/lib/third-party-security" import { workers } from "~Workers/worker-controller" import TransactionReviewDialog from "~TransactionReview/components/TransactionReviewDialog" import SubmissionProgress, { SubmissionType } from "./SubmissionProgress" @@ -186,8 +190,9 @@ class TransactionSender extends React.Component { } try { - if (await isStellarGuardProtected(horizon, account.publicKey)) { - await this.submitTransactionToStellarGuard(signedTx) + const thirdPartySecurityService = await isThirdPartyProtected(horizon, account.publicKey) + if (thirdPartySecurityService) { + await this.submitTransactionToThirdPartyService(signedTx, thirdPartySecurityService) } else if (await requiresRemoteSignatures(horizon, signedTx, account.publicKey)) { await this.submitTransactionToMultisigService(signedTx) } else { @@ -251,12 +256,12 @@ class TransactionSender extends React.Component { } } - submitTransactionToStellarGuard = async (signedTransaction: Transaction) => { + submitTransactionToThirdPartyService = async (signedTransaction: Transaction, service: ThirdPartySecurityService) => { try { - const promise = submitTransactionToStellarGuard(signedTransaction, this.props.account.testnet) + const promise = submitTransactionToThirdPartyService(signedTransaction, service, this.props.account.testnet) this.setSubmissionPromise(promise) - this.setState({ submissionType: SubmissionType.stellarguard }) + this.setState({ submissionType: SubmissionType.thirdParty }) return await promise } catch (error) { throw explainSubmissionErrorResponse(error, this.props.t)