diff --git a/electron/src/bootstrap.ts b/electron/src/bootstrap.ts index 3e794d709..af149e5a2 100644 --- a/electron/src/bootstrap.ts +++ b/electron/src/bootstrap.ts @@ -5,6 +5,3 @@ app.name = "Sunce Wallet" // Needs to match the value in electron-build.yml app.setAppUserModelId("org.montelibero.sunce") - -// Disabled until we actually ship SEP-7 support -// app.setAsDefaultProtocolClient("web+stellar") diff --git a/electron/src/protocol-handler.ts b/electron/src/protocol-handler.ts index ca8225ff6..165cc4829 100644 --- a/electron/src/protocol-handler.ts +++ b/electron/src/protocol-handler.ts @@ -1,6 +1,8 @@ import { app } from "electron" import events from "events" import { createMainWindow, getOpenWindows, trackWindow } from "./window" +import { expose } from "./ipc/_ipc" +import { Messages } from "./shared/ipc" const urlEventEmitter = new events.EventEmitter() const urlEventChannel = "deeplink:url" @@ -8,6 +10,19 @@ const urlEventChannel = "deeplink:url" let urlEventQueue: string[] = [] let isWindowReady = false +expose(Messages.IsDefaultProtocolClient, () => { + return app.isDefaultProtocolClient("web+stellar") +}) + +expose(Messages.IsDifferentHandlerInstalled, () => { + const name = app.getApplicationNameForProtocol("web+stellar://") // '://' is needed here + return Boolean(name) +}) + +expose(Messages.SetAsDefaultProtocolClient, () => { + return app.setAsDefaultProtocolClient("web+stellar") +}) + export function subscribe(subscribeCallback: (...args: any[]) => void) { urlEventEmitter.on(urlEventChannel, subscribeCallback) const unsubscribe = () => urlEventEmitter.removeListener(urlEventChannel, subscribeCallback) diff --git a/i18n/en.ts b/i18n/en.ts index d0a0e4a45..6357174ab 100644 --- a/i18n/en.ts +++ b/i18n/en.ts @@ -7,6 +7,7 @@ import Generic from "./locales/en/generic.json" import Operations from "./locales/en/operations.json" import Payment from "./locales/en/payment.json" import Trading from "./locales/en/trading.json" +import TransactionRequest from "./locales/en/transaction-request.json" import TransferService from "./locales/en/transfer-service.json" const translations = { @@ -19,6 +20,7 @@ const translations = { operations: Operations, payment: Payment, trading: Trading, + "transaction-request": TransactionRequest, "transfer-service": TransferService } as const diff --git a/i18n/es.ts b/i18n/es.ts index 40c0d7632..3f0db30a7 100644 --- a/i18n/es.ts +++ b/i18n/es.ts @@ -7,6 +7,7 @@ import Generic from "./locales/es/generic.json" import Operations from "./locales/es/operations.json" import Payment from "./locales/es/payment.json" import Trading from "./locales/es/trading.json" +import TransactionRequest from "./locales/es/transaction-request.json" import TransferService from "./locales/es/transfer-service.json" const translations = { @@ -19,6 +20,7 @@ const translations = { operations: Operations, payment: Payment, trading: Trading, + "transaction-request": TransactionRequest, "transfer-service": TransferService } as const diff --git a/i18n/it.ts b/i18n/it.ts index 9aea1f08b..65b60cd5c 100644 --- a/i18n/it.ts +++ b/i18n/it.ts @@ -7,6 +7,7 @@ import Generic from "./locales/it/generic.json" import Operations from "./locales/it/operations.json" import Payment from "./locales/it/payment.json" import Trading from "./locales/it/trading.json" +import TransactionRequest from "./locales/it/transaction-request.json" import TransferService from "./locales/it/transfer-service.json" const translations = { @@ -19,6 +20,7 @@ const translations = { operations: Operations, payment: Payment, trading: Trading, + "transaction-request": TransactionRequest, "transfer-service": TransferService } as const diff --git a/i18n/locales/en/app-settings.json b/i18n/locales/en/app-settings.json index d59f9fd0e..713f25c41 100644 --- a/i18n/locales/en/app-settings.json +++ b/i18n/locales/en/app-settings.json @@ -44,6 +44,15 @@ } } }, + "protocol-handler": { + "text": { + "primary": "Handle Stellar protocol requests", + "secondary": { + "default": "Solar is handling all Stellar links", + "non-default": "Make Solar handle all Stellar URIs" + } + } + }, "testnet": { "text": { "primary": "Show Testnet Accounts", diff --git a/i18n/locales/en/app.json b/i18n/locales/en/app.json index 208972764..eeb478863 100644 --- a/i18n/locales/en/app.json +++ b/i18n/locales/en/app.json @@ -56,6 +56,15 @@ "text": "Sunce Wallet will now show notifications" }, "message": "Enable app notifications" + }, + "protocol-handler": { + "error": "Could not register Solar as default handler.", + "message": "Do you want Solar to handle interactive Stellar links on this computer (recommended)?", + "success": "Successfully registered Solar as default handler.", + "tooltip": { + "dismiss": "Dismiss", + "install": "Install" + } } } }, diff --git a/i18n/locales/en/generic.json b/i18n/locales/en/generic.json index f43ac81e4..24d19a07d 100644 --- a/i18n/locales/en/generic.json +++ b/i18n/locales/en/generic.json @@ -40,12 +40,14 @@ "request-failed-error": "Request to {{target}} failed with status {{status}}: {{message}}", "stellar-address-not-found-error": "Stellar address not found: {{address}}", "stellar-address-request-failed-error": "Stellar address resolution of {{address}} failed.", + "stellar-uri-verification-error": "Stellar URI's signature could not be verified.", "submission-failed-error": "Submitting transaction to {{endpoint}} failed with status {{status}}: {{message}}", "testnet-endpoint-not-available-error": "{{service}} does not provide a testnet endpoint.", "timeout-error": "Request timed out", "unexpected-action-error": "Unexpected action: {{action}}", "unexpected-state-error": "Encountered unexpected state: {{state}}", "unexpected-response-type-error": "Unexpected response type: {{type}} / ${dataType}", + "unexpected-stellar-uri-type-error": "Incoming uri {{incomingURI}} does not match any expected type.", "unknown-error": "An unknown error occured.", "update-already-running-error": "Update is already running!", "wrong-password-error": "Wrong password.", diff --git a/i18n/locales/en/payment.json b/i18n/locales/en/payment.json index 79dc1d0bb..3d598b65d 100644 --- a/i18n/locales/en/payment.json +++ b/i18n/locales/en/payment.json @@ -1,5 +1,6 @@ { "actions": { + "dismiss": "Dismiss", "submit": "Send now" }, "memo-metadata": { diff --git a/i18n/locales/en/transaction-request.json b/i18n/locales/en/transaction-request.json new file mode 100644 index 000000000..e492c596b --- /dev/null +++ b/i18n/locales/en/transaction-request.json @@ -0,0 +1,64 @@ +{ + "no-accounts": { + "action": { + "dismiss": "Dismiss" + }, + "info": { + "1": "No accounts found for the specified network.", + "2": "You must import an account before you can sign transaction requests." + }, + "title": "Transaction proposed" + }, + "payment": { + "account-selector": "Select the account to use", + "action": { + "dismiss": "Dismiss", + "select": "Select" + }, + "error": { + "no-activated-accounts": "No activated accounts found.", + "no-accounts-with-trustline": "No accounts with matching trustline found." + }, + "uri-content": { + "message": "Message" + } + }, + "transaction": { + "account-selector": { + "source-account": "Select the source account", + "signing-account": "Select the account that will sign the transaction" + }, + "action": { + "dismiss": "Dismiss", + "select": "Select" + }, + "error": { + "signer-not-imported": "The transaction request specified '{{signer}}' as the target signer but this account is not imported.", + "no-eligible-accounts": "No eligible account found." + }, + "hint": "Hint", + "uri-content": { + "message": "Message" + }, + "warning": "The origin of this request cannot be verified! Decline when in doubt." + }, + "stellar-uri": { + "header": { + "origin-domain": "The following transaction has been proposed by <1>{{originDomain}}<3>", + "warning": "The origin of this request cannot be verified! Decline when in doubt." + }, + "title": "Transaction proposed" + }, + "verify-trusted-service": { + "action": { + "trust": "Trust", + "cancel": "Cancel" + }, + "info": { + "1": "You opened a Stellar URI originating from an unknown origin", + "2": "If you trust this domain you can add this service to your list of trusted services.", + "3": "You can view and edit your list of trusted services anytime in the application settings." + }, + "title": "Verify Trusted Service" + } +} diff --git a/i18n/locales/es/generic.json b/i18n/locales/es/generic.json index d824cc44e..843dd4430 100644 --- a/i18n/locales/es/generic.json +++ b/i18n/locales/es/generic.json @@ -35,12 +35,14 @@ "request-failed-error": "Solicitud a {{target}} ha fallado con estado {{status}}: {{message}}", "stellar-address-not-found-error": "Dirección Stellar no encontrada: {{address}}", "stellar-address-request-failed-error": "Falló la resolución de la dirección Stellar {{address}}", + "stellar-uri-verification-error": "No se ha podido verificar la firma de Stellar URI", "submission-failed-error": "Envío de transacción a {{endpoint}} falló con estado {{status}}: {{message}}", "testnet-endpoint-not-available-error": "{{service}} no provee un punto de conexión Testnet.", "timeout-error": "Solicitud ha caducado", "unexpected-action-error": "Acción imprevista: {{action}}", "unexpected-state-error": "Se encontró un estado inesperado: {{state}}", "unexpected-response-type-error": "Tipo de respuesta imprevista: {{type}} / ${dataType}", + "unexpected-stellar-uri-type-error": "La uri entrante {{incomingURI}} no coincide con ningún tipo esperado.", "unknown-error": "Ocurrió un error desconocido.", "update-already-running-error": "¡La actualización ya se está ejecutando!", "wrong-password-error": "Contraseña equivocada.", @@ -96,4 +98,4 @@ "user-interface": { "copied-to-clipboard": "Copiado a Portapapeles." } -} \ No newline at end of file +} diff --git a/i18n/locales/es/payment.json b/i18n/locales/es/payment.json index 93842e100..b8da66d15 100644 --- a/i18n/locales/es/payment.json +++ b/i18n/locales/es/payment.json @@ -1,5 +1,6 @@ { "actions": { + "dismiss": "Desechar", "submit": "Enviar ahora" }, "memo-metadata": { diff --git a/i18n/locales/es/transaction-request.json b/i18n/locales/es/transaction-request.json new file mode 100644 index 000000000..e492c596b --- /dev/null +++ b/i18n/locales/es/transaction-request.json @@ -0,0 +1,64 @@ +{ + "no-accounts": { + "action": { + "dismiss": "Dismiss" + }, + "info": { + "1": "No accounts found for the specified network.", + "2": "You must import an account before you can sign transaction requests." + }, + "title": "Transaction proposed" + }, + "payment": { + "account-selector": "Select the account to use", + "action": { + "dismiss": "Dismiss", + "select": "Select" + }, + "error": { + "no-activated-accounts": "No activated accounts found.", + "no-accounts-with-trustline": "No accounts with matching trustline found." + }, + "uri-content": { + "message": "Message" + } + }, + "transaction": { + "account-selector": { + "source-account": "Select the source account", + "signing-account": "Select the account that will sign the transaction" + }, + "action": { + "dismiss": "Dismiss", + "select": "Select" + }, + "error": { + "signer-not-imported": "The transaction request specified '{{signer}}' as the target signer but this account is not imported.", + "no-eligible-accounts": "No eligible account found." + }, + "hint": "Hint", + "uri-content": { + "message": "Message" + }, + "warning": "The origin of this request cannot be verified! Decline when in doubt." + }, + "stellar-uri": { + "header": { + "origin-domain": "The following transaction has been proposed by <1>{{originDomain}}<3>", + "warning": "The origin of this request cannot be verified! Decline when in doubt." + }, + "title": "Transaction proposed" + }, + "verify-trusted-service": { + "action": { + "trust": "Trust", + "cancel": "Cancel" + }, + "info": { + "1": "You opened a Stellar URI originating from an unknown origin", + "2": "If you trust this domain you can add this service to your list of trusted services.", + "3": "You can view and edit your list of trusted services anytime in the application settings." + }, + "title": "Verify Trusted Service" + } +} diff --git a/i18n/locales/it/generic.json b/i18n/locales/it/generic.json index 4259ce46f..6e8f0f916 100755 --- a/i18n/locales/it/generic.json +++ b/i18n/locales/it/generic.json @@ -31,12 +31,14 @@ "request-failed-error": "Richiesta a {{target}} fallita {{status}}: {{message}}", "stellar-address-not-found-error": "Indirizzo stellare non trovato: {{address}}", "stellar-address-request-failed-error": "Risoluzione stellare dell'indirizzo di {{address}} non riuscita.", + "stellar-uri-verification-error": "Non è stato possibile verificare la firma di Stellar URI.", "submission-failed-error": "Invio della transazione a {{endpoint}} fallita {{status}}: {{message}}", "testnet-endpoint-not-available-error": "{{service}} non fornisce un endpoint testnet.", "timeout-error": "Richiesta scaduta", "unexpected-action-error": "Azione imprevista: {{action}}", "unexpected-state-error": "Stato imprevisto riscontrato: {{state}}", "unexpected-response-type-error": "Tipo di risposta imprevista: {{type}} / ${dataType}", + "unexpected-stellar-uri-type-error": "L'uri in arrivo {{incomingURI}} non corrisponde a nessun tipo previsto.", "unknown-error": "Si è verificato un errore sconosciuto.", "update-already-running-error": "L'aggiornamento è già in esecuzione!", "wrong-password-error": "Password errata.", diff --git a/i18n/locales/it/payment.json b/i18n/locales/it/payment.json index 2e1f34323..f9d41fb83 100755 --- a/i18n/locales/it/payment.json +++ b/i18n/locales/it/payment.json @@ -1,5 +1,6 @@ { "actions": { + "dismiss": "Respingi", "submit": "Spedisci ora" }, "memo-metadata": { diff --git a/i18n/locales/it/transaction-request.json b/i18n/locales/it/transaction-request.json new file mode 100644 index 000000000..e492c596b --- /dev/null +++ b/i18n/locales/it/transaction-request.json @@ -0,0 +1,64 @@ +{ + "no-accounts": { + "action": { + "dismiss": "Dismiss" + }, + "info": { + "1": "No accounts found for the specified network.", + "2": "You must import an account before you can sign transaction requests." + }, + "title": "Transaction proposed" + }, + "payment": { + "account-selector": "Select the account to use", + "action": { + "dismiss": "Dismiss", + "select": "Select" + }, + "error": { + "no-activated-accounts": "No activated accounts found.", + "no-accounts-with-trustline": "No accounts with matching trustline found." + }, + "uri-content": { + "message": "Message" + } + }, + "transaction": { + "account-selector": { + "source-account": "Select the source account", + "signing-account": "Select the account that will sign the transaction" + }, + "action": { + "dismiss": "Dismiss", + "select": "Select" + }, + "error": { + "signer-not-imported": "The transaction request specified '{{signer}}' as the target signer but this account is not imported.", + "no-eligible-accounts": "No eligible account found." + }, + "hint": "Hint", + "uri-content": { + "message": "Message" + }, + "warning": "The origin of this request cannot be verified! Decline when in doubt." + }, + "stellar-uri": { + "header": { + "origin-domain": "The following transaction has been proposed by <1>{{originDomain}}<3>", + "warning": "The origin of this request cannot be verified! Decline when in doubt." + }, + "title": "Transaction proposed" + }, + "verify-trusted-service": { + "action": { + "trust": "Trust", + "cancel": "Cancel" + }, + "info": { + "1": "You opened a Stellar URI originating from an unknown origin", + "2": "If you trust this domain you can add this service to your list of trusted services.", + "3": "You can view and edit your list of trusted services anytime in the application settings." + }, + "title": "Verify Trusted Service" + } +} diff --git a/i18n/locales/ru/generic.json b/i18n/locales/ru/generic.json index fa8012ffe..fffb42aeb 100644 --- a/i18n/locales/ru/generic.json +++ b/i18n/locales/ru/generic.json @@ -40,12 +40,14 @@ "request-failed-error": "Запрос к {{target}} завершился с ошибкой статуса {{status}}: {{message}}", "stellar-address-not-found-error": "Адрес Stellar не найден: {{address}}", "stellar-address-request-failed-error": "Не удалось найти адрес Stellar {{address}}.", + "stellar-uri-verification-error": "Не удалось верифицировать Stellar URI.", "submission-failed-error": "Не удалось отправить транзакцию на {{endpoint}} с ошибкой статуса {{status}}: {{message}}", "testnet-endpoint-not-available-error": "{{service}} не предоставляет конечную точку тестовой сети.", "timeout-error": "Время запроса истекло", "unexpected-action-error": "Неожиданное действие: {{action}}", "unexpected-state-error": "Обнаружено неожиданное состояние: {{state}}", "unexpected-response-type-error": "Неожиданный тип ответа: {{type}} / ${dataType}", + "unexpected-stellar-uri-type-error": "Запрос {{incomingURI}} не поддерживается", "unknown-error": "Произошла неизвестная ошибка.", "update-already-running-error": "Обновление уже запущено!", "wrong-password-error": "Неверный пароль.", diff --git a/i18n/locales/ru/payment.json b/i18n/locales/ru/payment.json index 063548555..49b5ae860 100644 --- a/i18n/locales/ru/payment.json +++ b/i18n/locales/ru/payment.json @@ -1,5 +1,6 @@ { "actions": { + "dismiss": "Отменить", "submit": "Отправить сейчас" }, "memo-metadata": { diff --git a/i18n/locales/ru/transaction-request.json b/i18n/locales/ru/transaction-request.json new file mode 100644 index 000000000..f990076dc --- /dev/null +++ b/i18n/locales/ru/transaction-request.json @@ -0,0 +1,64 @@ +{ + "no-accounts": { + "action": { + "dismiss": "Закрыть" + }, + "info": { + "1": "Нет аккаунтов для указанной сети.", + "2": "Вы должны добавить аккаунт, чтобы подписывать платёжные запросы" + }, + "title": "Предложена транзакция" + }, + "payment": { + "account-selector": "Выберите аккаунт", + "action": { + "dismiss": "Отменить", + "select": "Выбрать" + }, + "error": { + "no-activated-accounts": "Не найдено активных аккаунтов.", + "no-accounts-with-trustline": "Нет аккаунтов с подходящей линией доверия." + }, + "uri-content": { + "message": "Сообщение" + } + }, + "transaction": { + "account-selector": { + "source-account": "Выберите аккаунт источник", + "signing-account": "Выберите аккаунт, который будет подписывать" + }, + "action": { + "dismiss": "Отмена", + "select": "Выбрать" + }, + "error": { + "signer-not-imported": "Аккаунт '{{signer}}' выбран для подписи, но он не импортирован.", + "no-eligible-accounts": "Нет подходящих аккаунтов." + }, + "hint": "Подсказка", + "uri-content": { + "message": "Сообщение" + }, + "warning": "Источник этого запроса не может быть верифицирован. Отклоните запрос, если сомневаетесь." + }, + "stellar-uri": { + "header": { + "origin-domain": "Данная транзакция была предложена <1>{{originDomain}}<3>", + "warning": "Источник этого запроса не может быть верифицирован. Отклоните запрос, если сомневаетесь." + }, + "title": "Предложена транзакция" + }, + "verify-trusted-service": { + "action": { + "trust": "Доверять", + "cancel": "Отменить" + }, + "info": { + "1": "Вы открыли Stellar URI от неизвестного источника", + "2": "Если вы доверяете этому домену, то вы можете добавить этот сервис в список доверенных.", + "3": "Вы можете смотреть и редактировать список доверенных сервисов в настройках приложения." + }, + "title": "Верифицировать доверенный сервис" + } +} diff --git a/i18n/ru.ts b/i18n/ru.ts index aac7f223b..7a4e05092 100644 --- a/i18n/ru.ts +++ b/i18n/ru.ts @@ -7,6 +7,7 @@ import Generic from "./locales/ru/generic.json" import Operations from "./locales/ru/operations.json" import Payment from "./locales/ru/payment.json" import Trading from "./locales/ru/trading.json" +import TransactionRequest from "./locales/ru/transaction-request.json" import TransferService from "./locales/ru/transfer-service.json" const translations = { @@ -19,6 +20,7 @@ const translations = { operations: Operations, payment: Payment, trading: Trading, + "transaction-request": TransactionRequest, "transfer-service": TransferService } as const diff --git a/package-lock.json b/package-lock.json index 3979d548c..21752eae4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,14 +1,15 @@ { "name": "org.montelibero.sunce", - "version": "1.0.1", + "version": "1.1.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "org.montelibero.sunce", - "version": "1.0.1", + "version": "1.1.0", "license": "MIT", "dependencies": { + "@stellarguard/stellar-uri": "^2.0.0", "electron-context-menu": "^0.15.0", "electron-debug": "^3.0.1", "electron-is-dev": "^1.1.0", diff --git a/package.json b/package.json index 500fc9d62..813a0265b 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "org.montelibero.sunce", "displayName": "Sunce Wallet", - "version": "1.0.1", + "version": "1.1.0", "description": "Wallet for the Stellar payment network by Montelibero.", "license": "MIT", "private": true, @@ -51,6 +51,7 @@ } }, "dependencies": { + "@stellarguard/stellar-uri": "^2.0.0", "electron-context-menu": "^0.15.0", "electron-debug": "^3.0.1", "electron-is-dev": "^1.1.0", diff --git a/shared/ipc.ts b/shared/ipc.ts index a46a7db03..3a0859d50 100644 --- a/shared/ipc.ts +++ b/shared/ipc.ts @@ -16,6 +16,9 @@ export const Messages: IPC.MessageType = { OpenLink: "OpenLink", DeepLinkURL: "DeepLinkURL", + IsDefaultProtocolClient: "IsDefaultProtocolClient", + IsDifferentHandlerInstalled: "IsDifferentHandlerInstalled", + SetAsDefaultProtocolClient: "SetAsDefaultProtocolClient", CheckUpdateAvailability: "CheckUpdateAvailability", StartUpdate: "StartUpdate", diff --git a/shared/types/ipc.d.ts b/shared/types/ipc.d.ts index 1e864ae94..916d44073 100644 --- a/shared/types/ipc.d.ts +++ b/shared/types/ipc.d.ts @@ -24,6 +24,9 @@ declare namespace IPC { OpenLink: "OpenLink" DeepLinkURL: "DeepLinkURL" + IsDefaultProtocolClient: "IsDefaultProtocolClient" + IsDifferentHandlerInstalled: "IsDifferentHandlerInstalled" + SetAsDefaultProtocolClient: "SetAsDefaultProtocolClient" CheckUpdateAvailability: "CheckUpdateAvailability" StartUpdate: "StartUpdate" @@ -62,6 +65,9 @@ declare namespace IPC { [Messages.OpenLink]: (href: string) => void [Messages.DeepLinkURL]: () => string + [Messages.IsDefaultProtocolClient]: () => boolean + [Messages.IsDifferentHandlerInstalled]: () => boolean + [Messages.SetAsDefaultProtocolClient]: () => boolean [Messages.CheckUpdateAvailability]: () => boolean [Messages.StartUpdate]: () => void diff --git a/shared/types/platform.d.ts b/shared/types/platform.d.ts index ac926dea9..58b69a71b 100644 --- a/shared/types/platform.d.ts +++ b/shared/types/platform.d.ts @@ -1,6 +1,6 @@ interface TrustedService { domain: string - signingKey: string + signingKey?: string } declare namespace Platform { diff --git a/src/Account/components/AccountSelectionList.tsx b/src/Account/components/AccountSelectionList.tsx index 4b0d48ed6..1ed54f8ec 100644 --- a/src/Account/components/AccountSelectionList.tsx +++ b/src/Account/components/AccountSelectionList.tsx @@ -15,6 +15,7 @@ const isMobileDevice = process.env.PLATFORM === "android" || process.env.PLATFOR interface AccountSelectionListProps { accounts: Account[] disabled?: boolean + selectedAccount?: Account testnet: boolean onChange?: (account: Account) => void } @@ -30,7 +31,7 @@ function AccountSelectionList(props: AccountSelectionListProps) { } return ( - + {props.accounts.map((account, index) => ( ))} {props.accounts.length === 0 ? ( diff --git a/src/App/bootstrap/app-stage2.tsx b/src/App/bootstrap/app-stage2.tsx index 67c65c5e8..7b606d8f4 100644 --- a/src/App/bootstrap/app-stage2.tsx +++ b/src/App/bootstrap/app-stage2.tsx @@ -7,6 +7,7 @@ import { VerticalLayout } from "~Layout/components/Box" import { appIsLoaded } from "~SplashScreen/splash-screen" import ConnectionErrorListener from "~Toasts/components/ConnectionErrorListener" import NotificationContainer from "~Toasts/components/NotificationContainer" +import StellarUriHandler from "~TransactionRequest/components/StellarUriHandler" import AllAccountsPage from "../components/AccountListView" import AndroidBackButton from "../components/AndroidBackButton" import DesktopNotifications from "../components/DesktopNotifications" @@ -82,6 +83,7 @@ function Stage2() { {/* Notifications need to come after the -webkit-overflow-scrolling element on iOS */} + {process.env.PLATFORM === "android" ? : null} {process.env.PLATFORM === "android" || process.env.PLATFORM === "ios" ? : null} diff --git a/src/App/bootstrap/context.tsx b/src/App/bootstrap/context.tsx index 474acb9e8..37331b2f0 100644 --- a/src/App/bootstrap/context.tsx +++ b/src/App/bootstrap/context.tsx @@ -5,17 +5,20 @@ import { NotificationsProvider } from "../contexts/notifications" import { SettingsProvider } from "../contexts/settings" import { SignatureDelegationProvider } from "../contexts/signatureDelegation" import { StellarProvider } from "../contexts/stellar" +import { TransactionRequestProvider } from "../contexts/transactionRequest" export function ContextProviders(props: { children: React.ReactNode }) { return ( - - - {props.children} - - + + + + {props.children} + + + diff --git a/src/App/components/AccountListView.tsx b/src/App/components/AccountListView.tsx index 8d530e1d2..ea32e75ea 100644 --- a/src/App/components/AccountListView.tsx +++ b/src/App/components/AccountListView.tsx @@ -15,6 +15,7 @@ import MainTitle from "~Generic/components/MainTitle" import { useRouter } from "~Generic/hooks/userinterface" import getUpdater from "~Platform/updater" import AppNotificationPermission from "~Toasts/components/AppNotificationPermission" +import ProtocolHandlerPermission from "~Toasts/components/ProtocolHandlerPermission" import { AccountsContext } from "../contexts/accounts" import { NotificationsContext, trackError } from "../contexts/notifications" import { SettingsContext } from "../contexts/settings" @@ -138,6 +139,7 @@ function AllAccountsPage() { onCreateTestnetAccount={() => router.history.push(routes.newAccount(true))} /> + void +} + +const initialValues: ContextType = { + uri: null, + clearURI: () => undefined +} + +const TransactionRequestContext = React.createContext(initialValues) + +export function TransactionRequestProvider(props: Props) { + const [uri, setURI] = React.useState(null) + + const { t } = useTranslation() + + const clearURI = React.useCallback(() => setURI(null), []) + + const verifyStellarURI = React.useCallback(async (incomingURI: string) => { + try { + const parsedURI = await verifyTransactionRequest(incomingURI, { allowUnsafeTestnetURIs }) + + if (parsedURI.operation === StellarUriType.Transaction) { + // check if contained transaction is valid + const txURI = parsedURI as TransactionStellarUri + txURI.getTransaction() + } + + setURI(parsedURI) + } catch (error) { + trackError(error) + } + }, []) + + React.useEffect(() => { + const unsubscribe = subscribeToDeepLinkURLs(async incomingURI => { + const url = new URL(incomingURI) + switch (url.pathname) { + case StellarUriType.Transaction: + case StellarUriType.Pay: + verifyStellarURI(incomingURI) + break + default: + trackError( + CustomError( + "UnexpectedStellarUriTypeError", + t("unexpected-stellar-uri-type-error", `Incoming uri ${incomingURI} does not match any expected type.`, { + incomingURI + }) + ) + ) + break + } + }) + return unsubscribe + }, [verifyStellarURI]) + + return ( + {props.children} + ) +} + +export { ContextType as TransactionRequestContextType, TransactionRequestContext } diff --git a/src/App/cordova/protocol-handler.ts b/src/App/cordova/protocol-handler.ts index 764ce062f..ac70d1168 100644 --- a/src/App/cordova/protocol-handler.ts +++ b/src/App/cordova/protocol-handler.ts @@ -1,7 +1,21 @@ import { Messages } from "~shared/ipc" +import { expose } from "./ipc" export function registerURLHandler(contentWindow: Window, iframeReady: Promise) { window.handleOpenURL = handleOpenURL(contentWindow, iframeReady) + + // there is no way we can check for default handler in cordova + expose(Messages.IsDefaultProtocolClient, () => { + return true + }) + + expose(Messages.IsDifferentHandlerInstalled, () => { + return false + }) + + expose(Messages.SetAsDefaultProtocolClient, () => { + return true + }) } const handleOpenURL = (contentWindow: Window, iframeReady: Promise) => (url: string) => { diff --git a/src/AppSettings/components/AppSettings.tsx b/src/AppSettings/components/AppSettings.tsx index b5768bfee..238d1e9cc 100644 --- a/src/AppSettings/components/AppSettings.tsx +++ b/src/AppSettings/components/AppSettings.tsx @@ -8,6 +8,7 @@ import * as routes from "~App/routes" import { useIsMobile, useRouter } from "~Generic/hooks/userinterface" import { matchesRoute } from "~Generic/lib/routes" import Carousel from "~Layout/components/Carousel" +import { isDefaultProtocolClient, setAsDefaultProtocolClient } from "~platform/protocol-handler" import ManageTrustedServicesDialog from "./ManageTrustedServicesDialog" import { BiometricLockSetting, @@ -17,7 +18,8 @@ import { ShowClaimableBalanceSetting, ShowDustSetting, TestnetSetting, - TrustedServicesSetting + TrustedServicesSetting, + ProtocolHandlerSetting } from "./Settings" const SettingsDialogs = React.memo(function SettingsDialogs() { @@ -32,11 +34,12 @@ function AppSettings() { const router = useRouter() const { i18n } = useTranslation() + const [isDefaultHandler, setIsDefaultHandler] = React.useState(false) + const showSettingsOverview = matchesRoute(router.location.pathname, routes.settings(), true) const { accounts } = React.useContext(AccountsContext) const settings = React.useContext(SettingsContext) - const trustedServicesEnabled = process.env.TRUSTED_SERVICES && process.env.TRUSTED_SERVICES === "enabled" const getEffectiveLanguage = (lang: L, fallback: F) => { return availableLanguages.indexOf(lang as any) > -1 ? lang : fallback @@ -55,6 +58,12 @@ function AppSettings() { [i18n, settings] ) + isDefaultProtocolClient().then(setIsDefaultHandler) + + const setDefaultClient = React.useCallback(() => { + setAsDefaultProtocolClient().then(success => setIsDefaultHandler(success)) + }, [setIsDefaultHandler]) + return ( @@ -80,7 +89,8 @@ function AppSettings() { onToggle={settings.toggleShowClaimableBalanceTxs} value={settings.showClaimableBalanceTxs} /> - {trustedServicesEnabled ? : undefined} + + diff --git a/src/AppSettings/components/Settings.tsx b/src/AppSettings/components/Settings.tsx index 9deeef512..ea31126fb 100644 --- a/src/AppSettings/components/Settings.tsx +++ b/src/AppSettings/components/Settings.tsx @@ -10,6 +10,7 @@ import FingerprintIcon from "@material-ui/icons/Fingerprint" import BlurOffIcon from "@material-ui/icons/BlurOff" import ReportOffIcon from "@material-ui/icons/ReportOff" import GroupIcon from "@material-ui/icons/Group" +import ProtocolHandlerIcon from "@material-ui/icons/AddCircleOutline" import LanguageIcon from "@material-ui/icons/Language" import MessageIcon from "@material-ui/icons/Message" import TestnetIcon from "@material-ui/icons/MoneyOff" @@ -248,3 +249,27 @@ export const TrustedServicesSetting = React.memo(function TrustedServicesSetting /> ) }) + +interface ProtocolHandlerSettingProps { + onClick: () => void + isDefaultHandler: boolean +} + +export const ProtocolHandlerSetting = React.memo(function ProtocolHandlerSetting(props: ProtocolHandlerSettingProps) { + const classes = useSettingsStyles(props) + const { t } = useTranslation() + + return ( + } + onClick={props.onClick} + primaryText={t("app-settings.settings.protocol-handler.text.primary")} + secondaryText={ + props.isDefaultHandler + ? t("app-settings.settings.protocol-handler.text.secondary.default") + : t("app-settings.settings.protocol-handler.text.secondary.non-default") + } + /> + ) +}) diff --git a/src/Generic/components/AssetSelector.tsx b/src/Generic/components/AssetSelector.tsx index 2cae6d91b..deec8af65 100644 --- a/src/Generic/components/AssetSelector.tsx +++ b/src/Generic/components/AssetSelector.tsx @@ -73,6 +73,7 @@ interface AssetSelectorProps { assets: (Asset | BalanceLine)[] children?: React.ReactNode className?: string + disabled?: boolean disabledAssets?: Asset[] disableUnderline?: boolean helperText?: TextFieldProps["helperText"] @@ -124,6 +125,7 @@ function AssetSelector(props: AssetSelectorProps) { ) { +function ConnectedPaymentDialog(props: Pick & { onSubmissionCompleted?: () => void }) { const accountData = useLiveAccountData(props.account.accountID, props.account.testnet) const { offers: openOrders } = useLiveAccountOffers(props.account.accountID, props.account.testnet) return ( - + {({ horizon, sendTransaction }) => ( void onSubmit: ( formValues: ExtendedPaymentFormValues, spendableBalance: BigNumber, wellknownAccount?: AccountRecord ) => void openOrdersCount: number + preselectedParams?: PaymentParams testnet: boolean trustedAssets: Asset[] txCreationPending?: boolean @@ -87,6 +98,7 @@ const PaymentForm = React.memo(function PaymentForm(props: PaymentFormProps) { }) const formValues = form.watch() + const { preselectedParams } = props const { setValue } = form const spendableBalance = getSpendableBalance( @@ -94,6 +106,15 @@ const PaymentForm = React.memo(function PaymentForm(props: PaymentFormProps) { findMatchingBalanceLine(props.accountData.balances, formValues.asset) ) + React.useEffect(() => { + if (!preselectedParams) return + + if (preselectedParams.amount) setValue("amount", preselectedParams.amount) + if (preselectedParams.asset) setValue("asset", preselectedParams.asset) + if (preselectedParams.destination) setValue("destination", preselectedParams.destination) + if (preselectedParams.memo) setValue("memoValue", preselectedParams.memo) + }, [preselectedParams, setValue]) + React.useEffect(() => { if (!isPublicKey(formValues.destination) && !isStellarAddress(formValues.destination)) { if (matchingWellknownAccount) { @@ -105,7 +126,17 @@ const PaymentForm = React.memo(function PaymentForm(props: PaymentFormProps) { const knownAccount = wellknownAccounts.lookup(formValues.destination) setMatchingWellknownAccount(knownAccount) - if (knownAccount && knownAccount.tags.indexOf("exchange") !== -1) { + if (preselectedParams && preselectedParams.memo && preselectedParams.memoType) { + setMemoType(preselectedParams.memoType) + setMemoMetadata({ + label: + preselectedParams.memoType === "id" + ? t("payment.memo-metadata.label.id") + : t("payment.memo-metadata.label.text"), + placeholder: t("payment.memo-metadata.placeholder.mandatory"), + requiredType: preselectedParams.memoType + }) + } else if (knownAccount && knownAccount.tags.indexOf("exchange") !== -1) { const acceptedMemoType = knownAccount.accepts && knownAccount.accepts.memo const requiredType = acceptedMemoType === "MEMO_ID" ? "id" : "text" setMemoType(requiredType) @@ -123,7 +154,15 @@ const PaymentForm = React.memo(function PaymentForm(props: PaymentFormProps) { requiredType: undefined }) } - }, [formValues.destination, formValues.memoValue, matchingWellknownAccount, memoType, t, wellknownAccounts]) + }, [ + formValues.destination, + formValues.memoValue, + matchingWellknownAccount, + memoType, + preselectedParams, + t, + wellknownAccounts + ]) const handleFormSubmission = () => { props.onSubmit({ memoType, ...form.getValues() }, spendableBalance, matchingWellknownAccount) @@ -162,13 +201,14 @@ const PaymentForm = React.memo(function PaymentForm(props: PaymentFormProps) { () => ( ("payment.validation.no-destination"), @@ -185,7 +225,7 @@ const PaymentForm = React.memo(function PaymentForm(props: PaymentFormProps) { placeholder={t("payment.inputs.destination.placeholder")} /> ), - [form, qrReaderAdornment, setValue, t] + [form, qrReaderAdornment, preselectedParams, setValue, t] ) const assetSelector = React.useMemo( @@ -195,6 +235,7 @@ const PaymentForm = React.memo(function PaymentForm(props: PaymentFormProps) { ), - [form, formValues.asset, props.accountData.balances, props.testnet] + [form, formValues.asset, preselectedParams, props.accountData.balances, props.testnet] ) const priceInput = React.useMemo( () => ( ("payment.validation.no-price"), @@ -243,12 +285,13 @@ const PaymentForm = React.memo(function PaymentForm(props: PaymentFormProps) { }} /> ), - [assetSelector, form, isSmallScreen, spendableBalance, t] + [assetSelector, form, isSmallScreen, preselectedParams, spendableBalance, t] ) const memoInput = React.useMemo( () => ( ( + {props.onCancel && ( + } onClick={props.onCancel}> + {t("payment.actions.dismiss")} + + )} } @@ -316,7 +365,7 @@ const PaymentForm = React.memo(function PaymentForm(props: PaymentFormProps) { ), - [formID, props.txCreationPending, t] + [formID, props.onCancel, props.txCreationPending, t] ) return ( @@ -335,10 +384,11 @@ interface Props { accountData: AccountData actionsRef: RefStateObject openOrdersCount: number + preselectedParams?: PaymentParams testnet: boolean trustedAssets: Asset[] txCreationPending?: boolean - onCancel: () => void + onCancel?: () => void onSubmit: (createTx: (horizon: Server, account: Account) => Promise) => any } diff --git a/src/Platform/ipc/web.ts b/src/Platform/ipc/web.ts index 842cf60c4..669a1ffbf 100644 --- a/src/Platform/ipc/web.ts +++ b/src/Platform/ipc/web.ts @@ -59,6 +59,21 @@ callHandlers[Messages.ShowNotification] = (localNotification: LocalNotification) }) } +let isDefault = false +let differentHandler = true +callHandlers[Messages.IsDifferentHandlerInstalled] = () => differentHandler +callHandlers[Messages.IsDefaultProtocolClient] = () => isDefault +callHandlers[Messages.SetAsDefaultProtocolClient] = () => { + window.navigator.registerProtocolHandler( + "web+stellar", + `${window.location.origin}/?uri=%s`, + "Stellar request handler" + ) + isDefault = true + differentHandler = false + return true +} + const defaultTestingKeys: KeysData = { "1": { metadata: { @@ -147,17 +162,20 @@ function initKeyStore() { callHandlers[Messages.SignTransaction] = signTransaction } +const defaultSettings: Platform.SettingsData = { + agreedToTermsAt: "2019-01-17T07:34:05.688Z", + biometricLock: false, + multisignature: true, + testnet: true, + trustedServices: [], + hideMemos: false, + showDust: false, + showClaimableBalanceTxs: false +} + function initSettings() { - let settings: Platform.SettingsData = { - agreedToTermsAt: "2019-01-17T07:34:05.688Z", - biometricLock: false, - multisignature: true, - testnet: true, - trustedServices: [], - hideMemos: false, - showDust: false, - showClaimableBalanceTxs: false - } + const storedSettings = localStorage.getItem("sunce:settings") + let settings = storedSettings ? JSON.parse(storedSettings) : defaultSettings callHandlers[Messages.BioAuthAvailable] = () => ({ available: false, enrolled: false }) @@ -167,6 +185,8 @@ function initSettings() { ...settings, ...updatedSettings } + + localStorage.setItem("sunce:settings", JSON.stringify(settings)) } callHandlers[Messages.ReadIgnoredSignatureRequestHashes] = () => { @@ -183,12 +203,6 @@ function initSettings() { } function subscribeToDeepLinkURLs(callback: (url: string) => void) { - window.navigator.registerProtocolHandler( - "web+stellar", - `${window.location.origin}/?uri=%s`, - "Stellar request handler" - ) - // check if a stellar uri has been passed already const uri = new URLSearchParams(window.location.search).get("uri") if (uri) { diff --git a/src/Platform/protocol-handler.ts b/src/Platform/protocol-handler.ts index 6d005f550..0de3d098c 100644 --- a/src/Platform/protocol-handler.ts +++ b/src/Platform/protocol-handler.ts @@ -1,6 +1,18 @@ -import { subscribeToMessages } from "./ipc" +import { call, subscribeToMessages } from "./ipc" import { Messages } from "../../shared/ipc" export function subscribeToDeepLinkURLs(callback: (url: string) => void) { return subscribeToMessages(Messages.DeepLinkURL, callback) } + +export function isDefaultProtocolClient() { + return call(Messages.IsDefaultProtocolClient) +} + +export function isDifferentHandlerInstalled() { + return call(Messages.IsDifferentHandlerInstalled) +} + +export function setAsDefaultProtocolClient() { + return call(Messages.SetAsDefaultProtocolClient) +} diff --git a/src/Toasts/components/ProtocolHandlerPermission.tsx b/src/Toasts/components/ProtocolHandlerPermission.tsx new file mode 100644 index 000000000..f69a420e8 --- /dev/null +++ b/src/Toasts/components/ProtocolHandlerPermission.tsx @@ -0,0 +1,116 @@ +import React from "react" +import { useTranslation } from "react-i18next" +import IconButton from "@material-ui/core/IconButton" +import Grow from "@material-ui/core/Grow" +import SnackbarContent from "@material-ui/core/SnackbarContent" +import Tooltip from "@material-ui/core/Tooltip" +import { useTheme } from "@material-ui/core/styles" +import CloseIcon from "@material-ui/icons/Close" +import CheckIcon from "@material-ui/icons/Check" +import { NotificationsContext } from "~App/contexts/notifications" +import { HorizontalLayout } from "~Layout/components/Box" +import { + isDefaultProtocolClient, + isDifferentHandlerInstalled, + setAsDefaultProtocolClient +} from "~platform/protocol-handler" + +function isNotificationDismissed() { + return localStorage.getItem("protocol-handler-notification-dismissed") !== null +} + +function setNotificationDismissed() { + localStorage.setItem("protocol-handler-notification-dismissed", "true") +} + +interface PermissionNotificationProps { + onHide: () => void + open: boolean +} + +const PermissionNotification = React.memo(function PermissionNotification(props: PermissionNotificationProps) { + const { onHide } = props + const { showNotification } = React.useContext(NotificationsContext) + const theme = useTheme() + const { t } = useTranslation() + + const requestPermission = React.useCallback(() => { + setAsDefaultProtocolClient().then(success => { + if (success) { + showNotification("success", t("app.notification.permission.protocol-handler.success")) + } else { + showNotification("error", t("app.notification.permission.protocol-handler.error")) + } + onHide() + }) + }, [onHide, showNotification, t]) + + const dismiss = React.useCallback(() => { + setNotificationDismissed() + onHide() + }, [onHide]) + + return ( + + + + {t("app.notification.permission.protocol-handler.message")} + + + + + + + + + + + + + } + style={{ + display: "flex", + alignItems: "center", + background: "white", + color: theme.palette.text.primary, + cursor: "pointer", + flexGrow: 0, + justifyContent: "center" + }} + /> + + ) +}) + +function ProtocolHandlerPermission() { + const [showPermissionNotification, setShowPermissionNotification] = React.useState(false) + + React.useEffect(() => { + if (isNotificationDismissed()) { + return + } + + isDefaultProtocolClient().then(isDefault => { + if (isDefault) { + setShowPermissionNotification(false) + } else { + isDifferentHandlerInstalled().then(isDifferentInstalled => { + if (!isDifferentInstalled) { + setAsDefaultProtocolClient() + setShowPermissionNotification(false) + } else { + setShowPermissionNotification(true) + } + }) + } + }) + }, []) + + const hidePermissionNotification = React.useCallback(() => setShowPermissionNotification(false), []) + + return +} + +export default React.memo(ProtocolHandlerPermission) diff --git a/src/Transaction/lib/stellar-uri.ts b/src/Transaction/lib/stellar-uri.ts new file mode 100644 index 000000000..7b1c3afe6 --- /dev/null +++ b/src/Transaction/lib/stellar-uri.ts @@ -0,0 +1,22 @@ +import i18next from "../../App/i18n" +import { parseStellarUri } from "@stellarguard/stellar-uri" +import { CustomError } from "~Generic/lib/errors" + +export interface VerificationOptions { + allowUnsafeTestnetURIs?: boolean +} + +export async function verifyTransactionRequest(request: string, options: VerificationOptions = {}) { + const parsedURI = parseStellarUri(request) + const isSignatureValid = await parsedURI.verifySignature() + + if (!isSignatureValid) { + if (parsedURI.isTestNetwork && options.allowUnsafeTestnetURIs) { + // ignore + } else { + throw CustomError("StellarUriVerificationError", i18next.t("stellar-uri-verification-error")) + } + } + + return parsedURI +} diff --git a/src/TransactionRequest/components/NoAccountsDialog.tsx b/src/TransactionRequest/components/NoAccountsDialog.tsx new file mode 100644 index 000000000..b660fc1d0 --- /dev/null +++ b/src/TransactionRequest/components/NoAccountsDialog.tsx @@ -0,0 +1,49 @@ +import Box from "@material-ui/core/Box" +import Typography from "@material-ui/core/Typography" +import CancelIcon from "@material-ui/icons/Close" +import React from "react" +import { useTranslation } from "react-i18next" +import { ActionButton, DialogActionsBox } from "~Generic/components/DialogActions" +import MainTitle from "~Generic/components/MainTitle" +import TestnetBadge from "~Generic/components/TestnetBadge" +import DialogBody from "~Layout/components/DialogBody" + +interface Props { + onClose: () => void + testnet: boolean +} + +function NoAccountsDialog(props: Props) { + const { t } = useTranslation() + return ( + + {t("transaction-request.no-accounts.title")} + {props.testnet ? : null} + + } + /> + } + actions={ + + } onClick={props.onClose} type="secondary"> + {t("transaction-request.no-accounts.action.dismiss")} + + + } + > + + {t("transaction-request.no-accounts.info.1")} + {t("transaction-request.no-accounts.info.2")} + + + ) +} + +export default NoAccountsDialog diff --git a/src/TransactionRequest/components/PaymentRequestContent.tsx b/src/TransactionRequest/components/PaymentRequestContent.tsx new file mode 100644 index 000000000..0ea0aed59 --- /dev/null +++ b/src/TransactionRequest/components/PaymentRequestContent.tsx @@ -0,0 +1,157 @@ +import Box from "@material-ui/core/Box" +import Typography from "@material-ui/core/Typography" +import { PayStellarUri } from "@stellarguard/stellar-uri" +import React from "react" +import { useTranslation } from "react-i18next" +import { Asset, Server, Transaction } from "stellar-sdk" +import AccountSelectionList from "~Account/components/AccountSelectionList" +import { Account } from "~App/contexts/accounts" +import { trackError } from "~App/contexts/notifications" +import ViewLoading from "~Generic/components/ViewLoading" +import { useLiveAccountDataSet, useLiveAccountOffers } from "~Generic/hooks/stellar-subscriptions" +import { RefStateObject } from "~Generic/hooks/userinterface" +import { AccountData } from "~Generic/lib/account" +import { getAssetsFromBalances } from "~Generic/lib/stellar" +import { PaymentParams } from "~Payment/components/PaymentForm" +import PaymentForm from "~Payment/components/PaymentForm" +import { SendTransaction } from "../../Transaction/components/TransactionSender" + +interface ConnectedPaymentFormProps { + accountData: AccountData + actionsRef: RefStateObject + horizon: Server + onClose: () => void + preselectedParams: PaymentParams + selectedAccount: Account + sendTransaction: SendTransaction +} + +function ConnectedPaymentForm(props: ConnectedPaymentFormProps) { + const { sendTransaction } = props + const testnet = props.selectedAccount.testnet + + const [txCreationPending, setTxCreationPending] = React.useState(false) + const { offers: openOrders } = useLiveAccountOffers(props.selectedAccount.publicKey, testnet) + const trustedAssets = React.useMemo(() => getAssetsFromBalances(props.accountData.balances) || [Asset.native()], [ + props.accountData.balances + ]) + + const handleSubmit = React.useCallback( + async (createTx: (horizon: Server, account: Account) => Promise) => { + try { + setTxCreationPending(true) + const tx = await createTx(props.horizon, props.selectedAccount) + setTxCreationPending(false) + await sendTransaction(tx) + } catch (error) { + trackError(error) + } finally { + setTxCreationPending(false) + } + }, + [props.selectedAccount, props.horizon, sendTransaction] + ) + + return ( + + ) +} + +interface PaymentRequestContentProps { + accounts: Account[] + actionsRef: RefStateObject + horizon: Server + onAccountChange: (account: Account) => void + onClose: () => void + payStellarUri: PayStellarUri + selectedAccount: Account | null + sendTransaction: SendTransaction +} + +function PaymentRequestContent(props: PaymentRequestContentProps) { + const { + amount, + assetCode, + assetIssuer, + destination, + memo, + memoType, + msg, + isTestNetwork: testnet + } = props.payStellarUri + + const { t } = useTranslation() + + const accountDataSet = useLiveAccountDataSet( + props.accounts.map(acc => acc.publicKey), + testnet + ) + const accountData = accountDataSet.find(acc => acc.account_id === props.selectedAccount?.publicKey) + const asset = React.useMemo(() => (assetCode && assetIssuer ? new Asset(assetCode, assetIssuer) : Asset.native()), [ + assetCode, + assetIssuer + ]) + const paymentParams = React.useMemo(() => { + return { + amount, + asset, + destination, + memo, + memoType + } + }, [amount, asset, destination, memo, memoType]) + + return ( + + }> + {props.selectedAccount && accountData && ( + <> + {msg && ( + + {t("transaction-request.payment.uri-content.message")}: {msg} + + )} + + + )} + + + {t("transaction-request.payment.account-selector")} + + {props.accounts.length > 0 ? ( + + ) : ( + + {asset.code === "XLM" + ? t("transaction-request.payment.error.no-activated-accounts") + : t("transaction-request.payment.error.no-accounts-with-trustline")} + + )} + + ) +} + +export default PaymentRequestContent diff --git a/src/TransactionRequest/components/StellarRequestReviewDialog.tsx b/src/TransactionRequest/components/StellarRequestReviewDialog.tsx new file mode 100644 index 000000000..6f3abbfc7 --- /dev/null +++ b/src/TransactionRequest/components/StellarRequestReviewDialog.tsx @@ -0,0 +1,123 @@ +import Box from "@material-ui/core/Box" +import { makeStyles } from "@material-ui/core/styles" +import Typography from "@material-ui/core/Typography" +import WarningIcon from "@material-ui/icons/Warning" +import { PayStellarUri, StellarUri, StellarUriType, TransactionStellarUri } from "@stellarguard/stellar-uri" +import React from "react" +import { Trans, useTranslation } from "react-i18next" +import { Account, AccountsContext } from "~App/contexts/accounts" +import { breakpoints, warningColor } from "~App/theme" +import MainTitle from "~Generic/components/MainTitle" +import { RefStateObject, useDialogActions } from "~Generic/hooks/userinterface" +import DialogBody from "~Layout/components/DialogBody" +import TransactionSender from "../../Transaction/components/TransactionSender" +import NoAccountsDialog from "./NoAccountsDialog" +import PaymentRequestContent from "./PaymentRequestContent" +import TransactionRequestContent from "./TransactionRequestContent" + +const useStyles = makeStyles(() => ({ + root: { + display: "flex", + flexDirection: "column", + padding: "12px 0 0" + }, + warningContainer: { + alignItems: "center", + alignSelf: "center", + background: warningColor, + display: "flex", + justifyContent: "center", + padding: "6px 16px", + width: "fit-content", + + [breakpoints.up(600)]: { + width: "100%" + } + } +})) + +interface StellarRequestReviewDialogProps { + children: React.ReactNode + actionsRef: RefStateObject + stellarUri: StellarUri + onClose: () => void +} + +function StellarRequestReviewDialog(props: StellarRequestReviewDialogProps) { + const { onClose } = props + + const classes = useStyles() + const { t } = useTranslation() + + return ( + } + actions={props.actionsRef} + > + + {props.stellarUri.signature ? ( + + + The following transaction has been proposed by {{ originDomain: props.stellarUri.originDomain }}. + + + ) : ( + + + {t("transaction-request.stellar-uri.header.warning")} + + + )} + {props.children} + + + ) +} + +function ConnectedStellarRequestReviewDialog(props: Pick) { + const { accounts } = React.useContext(AccountsContext) + const testnet = props.stellarUri.isTestNetwork + const accountsForNetwork = React.useMemo(() => accounts.filter(acc => acc.testnet === testnet), [accounts, testnet]) + const [selectedAccount, setSelectedAccount] = React.useState( + accountsForNetwork.length > 0 ? accountsForNetwork[0] : null + ) + + const dialogActionsRef = useDialogActions() + + return accountsForNetwork.length > 0 ? ( + + {({ horizon, sendTransaction }) => ( + + {props.stellarUri.operation === StellarUriType.Pay ? ( + + ) : ( + + )} + + )} + + ) : ( + + ) +} + +export default ConnectedStellarRequestReviewDialog diff --git a/src/TransactionRequest/components/StellarUriHandler.tsx b/src/TransactionRequest/components/StellarUriHandler.tsx new file mode 100644 index 000000000..d185e9db9 --- /dev/null +++ b/src/TransactionRequest/components/StellarUriHandler.tsx @@ -0,0 +1,61 @@ +import { Dialog } from "@material-ui/core" +import Fade from "@material-ui/core/Fade" +import { TransitionProps } from "@material-ui/core/transitions/transition" +import { StellarUri } from "@stellarguard/stellar-uri" +import React from "react" +import { SettingsContext } from "~App/contexts/settings" +import { TransactionRequestContext } from "~App/contexts/transactionRequest" +import StellarRequestReviewDialog from "./StellarRequestReviewDialog" +import VerifyTrustedServiceDialog from "./VerifyTrustedServiceDialog" + +const Transition = React.forwardRef((props: TransitionProps, ref) => ) + +function StellarUriHandler() { + const { uri, clearURI } = React.useContext(TransactionRequestContext) + const { trustedServices, setSetting } = React.useContext(SettingsContext) + const [closedStellarURI, setClosedStellarURI] = React.useState(null) + + // We need that so we still know what to render when we fade out the dialog + const renderedURI = uri || closedStellarURI + + const closeDialog = React.useCallback(() => { + setClosedStellarURI(uri) + clearURI() + + // Clear location href, since it might contain secret search params + window.history.pushState({}, "Sunce Wallet", window.location.href.replace(window.location.search, "")) + }, [clearURI, uri]) + + if (!renderedURI) { + return null + } + + const trustedService = trustedServices.find(service => renderedURI.originDomain === service.domain) + + if (renderedURI.originDomain && !trustedService) { + const onTrust = () => { + const newTrustedServices: TrustedService[] = [ + ...trustedServices, + { domain: renderedURI.originDomain!, signingKey: renderedURI.pubkey } + ] + setSetting("trustedServices", newTrustedServices) + } + const onDeny = () => { + closeDialog() + } + + return ( + + + + ) + } + + return ( + + + + ) +} + +export default React.memo(StellarUriHandler) diff --git a/src/TransactionRequest/components/TransactionRequestContent.tsx b/src/TransactionRequest/components/TransactionRequestContent.tsx new file mode 100644 index 000000000..55ce6727b --- /dev/null +++ b/src/TransactionRequest/components/TransactionRequestContent.tsx @@ -0,0 +1,201 @@ +import Box from "@material-ui/core/Box" +import makeStyles from "@material-ui/core/styles/makeStyles" +import Typography from "@material-ui/core/Typography" +import CloseIcon from "@material-ui/icons/Close" +import SendIcon from "@material-ui/icons/Send" +import { TransactionStellarUri } from "@stellarguard/stellar-uri" +import BigNumber from "big.js" +import React from "react" +import { useTranslation } from "react-i18next" +import { Server } from "stellar-sdk" +import AccountSelectionList from "~Account/components/AccountSelectionList" +import { Account } from "~App/contexts/accounts" +import { trackError } from "~App/contexts/notifications" +import { ActionButton, DialogActionsBox } from "~Generic/components/DialogActions" +import Portal from "~Generic/components/Portal" +import { RefStateObject } from "~Generic/hooks/userinterface" +import { useTransactionTitle } from "~TransactionReview/components/TransactionReviewDialog" +import TransactionSummary from "~TransactionReview/components/TransactionSummary" +import { SendTransaction } from "../../Transaction/components/TransactionSender" + +const useStyles = makeStyles(() => ({ + root: { + display: "flex", + flexDirection: "column", + padding: "12px 0 0" + }, + uriContainer: { + paddingTop: 32, + paddingBottom: 32 + } +})) + +interface TransactionRequestContentProps { + accounts: Account[] + actionsRef: RefStateObject + horizon: Server + onClose: () => void + onAccountChange: (account: Account) => void + selectedAccount: Account | null + sendTransaction: SendTransaction + txStellarUri: TransactionStellarUri +} + +function TransactionRequestContent(props: TransactionRequestContentProps) { + const { onAccountChange, onClose, sendTransaction } = props + const { msg, pubkey, isTestNetwork: testnet } = props.txStellarUri + const transaction = React.useMemo(() => props.txStellarUri.getTransaction(), [props.txStellarUri]) + const replacements = React.useMemo(() => props.txStellarUri.getReplacements(), [props.txStellarUri]) + const sourceAccountReplacement = React.useMemo( + () => replacements.find(replacement => replacement.path === "sourceAccount"), + [replacements] + ) + const [txCreationPending, setTxCreationPending] = React.useState(false) + + const classes = useStyles() + const { t } = useTranslation() + const getTitle = useTransactionTitle() + + const selectableAccounts = React.useMemo(() => { + if (pubkey) { + // pubkey parameter specifies public key of the account that should sign + const requiredAccount = props.accounts.find(acc => acc.publicKey === pubkey) + return requiredAccount ? [requiredAccount] : [] + } else { + return props.accounts + } + }, [props.accounts, pubkey]) + + const getNewSeqNumber = React.useCallback( + async account => { + const fetchedSeqNum = await props.horizon.loadAccount(account).then(acc => acc.sequence) + const newSeqNum = BigNumber(fetchedSeqNum) + .add(1) + .toString() + return newSeqNum + }, + [props.horizon] + ) + + const onSelect = React.useCallback(async () => { + try { + setTxCreationPending(true) + const filledReplacements: { [key: string]: any } = {} + const seqNumReplacement = replacements.find(replacement => replacement.id === "seqNum") + if (seqNumReplacement) { + const sourceAccount = + sourceAccountReplacement && props.selectedAccount ? props.selectedAccount.publicKey : transaction.source + const newSeqNum = await getNewSeqNumber(sourceAccount) + filledReplacements[seqNumReplacement.id] = newSeqNum + } + if (sourceAccountReplacement && props.selectedAccount) { + const sourceAccount = + sourceAccountReplacement && props.selectedAccount ? props.selectedAccount.publicKey : transaction.source + filledReplacements[sourceAccountReplacement.id] = props.selectedAccount.publicKey + + if (!seqNumReplacement) { + // artificially add seqNum replacement to facilitate replacing seq number for new source account + const artificialSeqNumReplacement = { id: "SEQ", path: "seqNum", hint: "sequence number" } + props.txStellarUri.addReplacement(artificialSeqNumReplacement) + const newSeqNum = await getNewSeqNumber(sourceAccount) + filledReplacements[artificialSeqNumReplacement.id] = newSeqNum + } + } + + const newTx = props.txStellarUri.replace(filledReplacements).getTransaction() + sendTransaction(newTx) + } catch (error) { + trackError(error) + } finally { + setTxCreationPending(false) + } + }, [ + getNewSeqNumber, + props.txStellarUri, + props.selectedAccount, + replacements, + sourceAccountReplacement, + sendTransaction, + transaction.source + ]) + + const dialogActions = React.useMemo( + () => ( + + } onClick={onClose}> + {t("transaction-request.transaction.action.dismiss")} + + } + disabled={!props.selectedAccount} + loading={txCreationPending} + onClick={onSelect} + type="primary" + > + {t("transaction-request.transaction.action.select")} + + + ), + [onSelect, onClose, props.selectedAccount, t, txCreationPending] + ) + + return ( + + {msg && ( + + {t("transaciton-request.transaction.uri-content.message")}: + {msg} + + )} + + {getTitle(transaction)} + + + {sourceAccountReplacement ? ( + selectableAccounts.length > 0 ? ( + <> + + {t("transaction-request.transaction.account-selector.source-account")}
+
+ {sourceAccountReplacement.hint && ( + + {t("transaction-request.transaction.hint")}: {sourceAccountReplacement.hint} + + )} + + + ) : pubkey ? ( + + {t("transaction-request.transaction.error.signer-not-imported", { signer: pubkey })} + + ) : ( + + {t("transaction-request.transaction.error.no-eligible-account")} + + ) + ) : ( + <> + + {t("transaction-request.transaction.account-selector.signing-account")}
+
+ + + )} + {dialogActions} +
+ ) +} + +export default TransactionRequestContent diff --git a/src/TransactionRequest/components/VerifyTrustedServiceDialog.tsx b/src/TransactionRequest/components/VerifyTrustedServiceDialog.tsx new file mode 100644 index 000000000..5fb652d10 --- /dev/null +++ b/src/TransactionRequest/components/VerifyTrustedServiceDialog.tsx @@ -0,0 +1,51 @@ +import React from "react" +import { useTranslation } from "react-i18next" +import Box from "@material-ui/core/Box" +import Typography from "@material-ui/core/Typography" +import TrustIcon from "@material-ui/icons/Check" +import DenyIcon from "@material-ui/icons/Cancel" +import WarnIcon from "@material-ui/icons/Warning" +import { ActionButton, DialogActionsBox } from "~Generic/components/DialogActions" +import MainTitle from "~Generic/components/MainTitle" +import DialogBody from "~Layout/components/DialogBody" + +interface VerifyTrustedServiceDialogProps { + onTrust: () => void + onCancel: () => void + domain: string +} + +function VerifyTrustedServiceDialog(props: VerifyTrustedServiceDialogProps) { + const { t } = useTranslation() + + const { onTrust, onCancel } = props + + return ( + } + preventNotchSpacing + top={} + actions={ + + } onClick={onTrust} type="secondary"> + {t("transaction-request.verify-trusted-service.action.trust")} + + } onClick={onCancel} type="primary"> + {t("transaction-request.verify-trusted-service.action.cancel")} + + + } + > + + {t("transaction-request.verify-trusted-service.info.1")}: + + {props.domain} + + {t("transaction-request.verify-trusted-service.info.2")} + {t("transaction-request.verify-trusted-service.info.3")} + + + ) +} + +export default VerifyTrustedServiceDialog diff --git a/src/TransactionReview/components/TransactionReviewDialog.tsx b/src/TransactionReview/components/TransactionReviewDialog.tsx index 98d83c7a5..0709205ad 100644 --- a/src/TransactionReview/components/TransactionReviewDialog.tsx +++ b/src/TransactionReview/components/TransactionReviewDialog.tsx @@ -27,7 +27,7 @@ function isOfferDeletionOperation(operation: Operation) { ) } -function useTitle() { +export function useTransactionTitle() { const getOperationTitle = useOperationTitle() const { t } = useTranslation() @@ -65,7 +65,7 @@ interface TransactionReviewDialogBodyProps { export function TransactionReviewDialogBody(props: TransactionReviewDialogBodyProps) { const dialogActionsRef = useDialogActions() const isSmallScreen = useIsMobile() - const getTitle = useTitle() + const getTitle = useTransactionTitle() const titleContent = React.useMemo( () => ( diff --git a/src/TransactionReview/components/TransactionSummary.tsx b/src/TransactionReview/components/TransactionSummary.tsx index 314ae69fc..7d5874436 100644 --- a/src/TransactionReview/components/TransactionSummary.tsx +++ b/src/TransactionReview/components/TransactionSummary.tsx @@ -55,6 +55,7 @@ interface DefaultTransactionSummaryProps { accountData: AccountData canSubmit: boolean isDangerousSignatureRequest?: boolean + fullWidth?: boolean showHash?: boolean showSigners?: boolean showSource?: boolean @@ -97,7 +98,9 @@ function DefaultTransactionSummary(props: DefaultTransactionSummaryProps) { ) const isWideScreen = useMediaQuery("(min-width:900px)") - const widthStyling = isWideScreen ? { maxWidth: 700, minWidth: 400 } : { minWidth: "66vw" } + const widthStyling = isWideScreen + ? { maxWidth: props.fullWidth ? "unset" : 700, minWidth: 400 } + : { minWidth: "66vw" } const transaction = props.transaction as TransactionWithUndocumentedProps const transactionHash = React.useMemo(() => { @@ -222,6 +225,7 @@ function WebAuthTransactionSummary(props: WebAuthTransactionSummaryProps) { interface TransactionSummaryProps { account: Account | null canSubmit: boolean + fullWidth?: boolean showHash?: boolean showSource?: boolean signatureRequest?: MultisigTransactionResponse