diff --git a/package-lock.json b/package-lock.json index 3d683c0..e9e6867 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "sparkswap-desktop", - "version": "0.3.5", + "version": "0.3.6", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/package.json b/package.json index 2628322..45c00f0 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "author": "Sparkswap (https://github.com/sparkswap)", "description": "Sparkswap Desktop: the only way to buy Bitcoin instantly", "productName": "Sparkswap", - "version": "0.3.5", + "version": "0.3.6", "license": "MIT", "private": true, "main": "./build/electron.js", diff --git a/src/common/config.ts b/src/common/config.ts index 0bef47f..9d00e63 100644 --- a/src/common/config.ts +++ b/src/common/config.ts @@ -4,6 +4,7 @@ export const IS_TEST = process.env.REACT_APP_ENV === 'test' export const API_URL = IS_PRODUCTION ? 'https://stack.sparkswap.com' : 'http://localhost:3000' export const WEBSOCKETS_URL = IS_PRODUCTION ? 'wss://stack.sparkswap.com' : 'ws://localhost:3000' +export const PROOF_HOST = IS_PRODUCTION ? 'https://sparkswap.com' : 'http://localhost:3001' export const ZAPIER_HOOK = 'https://hooks.zapier.com/hooks/catch/5808043/o2fl9bt/' export const IP_API_URL = 'https://ipapi.co/json' diff --git a/src/global-shared/anchor-engine/anchor-engine.ts b/src/global-shared/anchor-engine/anchor-engine.ts index 0e911da..5dfd06f 100644 --- a/src/global-shared/anchor-engine/anchor-engine.ts +++ b/src/global-shared/anchor-engine/anchor-engine.ts @@ -135,9 +135,16 @@ export class AnchorEngine { `timeFromNow: ${timeFromNow}, finalDelta: ${finalDelta}`) } - const newEscrow = await api.createEscrow(this.apiKey, hash, recipientId, amount, expiration) - this.logger.debug(`Escrow for swap (${hash}) created`) - return newEscrow + try { + const newEscrow = await api.createEscrow(this.apiKey, hash, recipientId, amount, expiration) + this.logger.debug(`Escrow for swap (${hash}) created`) + return newEscrow + } catch (e) { + if (e.code && Object.values(api.CreateEscrowErrorCodes).includes(e.code)) { + throw new PermanentSwapError(`Error while creating escrow: ${e.reason}`) + } + throw e + } } private async waitForEscrowEnd (startedEscrow: api.Escrow): Promise { diff --git a/src/global-shared/anchor-engine/api.ts b/src/global-shared/anchor-engine/api.ts index e8358ec..d64e238 100644 --- a/src/global-shared/anchor-engine/api.ts +++ b/src/global-shared/anchor-engine/api.ts @@ -22,6 +22,23 @@ export enum EscrowStatus { complete = 'complete' } +export class AnchorError extends Error { + param?: string + code?: string + error?: string + reason?: string +} + +// see: https://www.anchorusd.com/9c0cba91e667a08e467f038b6e23e3c4/api/index.html#/?id=create-escrow +export enum CreateEscrowErrorCodes { + insufficientFunds = 'insufficient_funds', + recipientNotFound = 'not_found', + positiveDurationRequired = 'positive_duration_required', + duplicateTimeout = 'duplicate_timeout_parameters', + invalidHashFormat = 'invalid_format', + futureTimeoutRequired = 'future_timestamp_required' +} + // see: https://www.anchorusd.com/9c0cba91e667a08e467f038b6e23e3c4/api/index.html#/?id=the-escrow-object // Note: Hashes and preimages are hex on Anchor, but we use base64. So there needs to be a conversion // prior to sending to the Anchor API or receiving from it. @@ -173,7 +190,9 @@ export async function createEscrow (apiKey: string, hash: SwapHash, recipientId: amount: Amount, expiration: Date): Promise { const duration = Math.floor((expiration.getTime() - (new Date()).getTime()) / 1000) if (duration <= 0) { - throw new Error(`Escrow duration is too short (${duration}s)`) + const error = new AnchorError(`Escrow duration is too short (${duration}s)`) + error.code = CreateEscrowErrorCodes.positiveDurationRequired + throw error } const res = await request( apiKey, diff --git a/src/global-shared/api.ts b/src/global-shared/api.ts index e304bf6..80ecbcc 100644 --- a/src/global-shared/api.ts +++ b/src/global-shared/api.ts @@ -16,5 +16,6 @@ export const API_ENDPOINTS: { [key: string]: string } = { VERIFY_PHONE: '/verify-phone', START_BERBIX: '/start-berbix', FINISH_BERBIX: '/finish-berbix', - SUBMIT_PHOTO_ID: '/submit-photo-id' + SUBMIT_PHOTO_ID: '/submit-photo-id', + GET_PROOF: '/proof-of-keys' } diff --git a/src/global-shared/fetch-json.ts b/src/global-shared/fetch-json.ts index 7bae29c..2b756e7 100644 --- a/src/global-shared/fetch-json.ts +++ b/src/global-shared/fetch-json.ts @@ -1,12 +1,27 @@ import fetch, { Headers, RequestInit } from 'node-fetch' import logger from './logger' +interface FetchJsonErrorOpts { + status?: number, + code?: string, + error?: string, + reason?: string +} + class FetchJsonError extends Error { - statusCode?: number + status?: number + code?: string + error?: string + reason?: string - constructor (message?: string, statusCode?: number) { + constructor (message?: string, opts?: FetchJsonErrorOpts) { super(message) - this.statusCode = statusCode + if (opts) { + this.status = opts.status + this.code = opts.code + this.error = opts.error + this.reason = opts.reason + } } } @@ -46,7 +61,7 @@ Promise<{ ok: boolean, status: number, json: unknown }> { return { ok: res.ok, status: res.status, json } } catch (e) { const message = `Error while requesting "${httpOptions.method} ${url}": ${res.status} ${res.statusText}` - throw new FetchJsonError(message, res.status) + throw new FetchJsonError(message, { status: res.status }) } } @@ -71,7 +86,15 @@ export default async function fetchJSON (url: string, httpOptions: RequestInit, if (!ok && !(options.ignoreCodes && options.ignoreCodes.includes(status))) { const message = isUnknownJSON(json) ? getErrorMessage(json) : '' - throw new FetchJsonError(`Error while requesting "${httpOptions.method} ${url}": ${message}`, status) + const opts = { status } + if (isUnknownJSON(json)) { + Object.assign(opts, { + code: json.code, + error: json.error, + reason: json.reason + }) + } + throw new FetchJsonError(`Error while requesting "${httpOptions.method} ${url}": ${message}`, opts) } if (isUnknownJSON(json)) { diff --git a/src/global-shared/lnd-engine/config.ts b/src/global-shared/lnd-engine/config.ts new file mode 100644 index 0000000..93b5594 --- /dev/null +++ b/src/global-shared/lnd-engine/config.ts @@ -0,0 +1 @@ +export const SECONDS_PER_BLOCK = 600 diff --git a/src/global-shared/lnd-engine/connect-peer.ts b/src/global-shared/lnd-engine/connect-peer.ts new file mode 100644 index 0000000..8175d42 --- /dev/null +++ b/src/global-shared/lnd-engine/connect-peer.ts @@ -0,0 +1,25 @@ +import { LndActionOptions, GrpcError } from '../types/lnd-engine/client' +import { deadline } from './deadline' +import { promisify } from 'util' + +function alreadyConnected (err: GrpcError): boolean { + return (err && err.code === 2 && err.details != null && err.details.includes('already connected to peer')) +} + +export async function connectPeer (publicKey: string, host: string, { client, logger }: LndActionOptions): Promise { + const connect = promisify(client.connectPeer).bind(client) + const addr = { + pubkey: publicKey, + host + } + + try { + await connect({ addr }, { deadline: deadline() }) + } catch (e) { + if (alreadyConnected(e)) { + logger.debug(`Peer already connected: ${publicKey}`) + return + } + throw e + } +} diff --git a/src/global-shared/lnd-engine/create-channel.ts b/src/global-shared/lnd-engine/create-channel.ts new file mode 100644 index 0000000..30aa2ca --- /dev/null +++ b/src/global-shared/lnd-engine/create-channel.ts @@ -0,0 +1,73 @@ +import { + LndActionOptions, + OpenChannelResponse, + PendingUpdateResponse, + ChannelOpenResponse +} from '../types/lnd-engine/client' +import { SECONDS_PER_BLOCK } from './config' +import { connectPeer } from './connect-peer' +import { + parse as parseAddress, + loggablePubKey +} from './utils' + +// Default number of seconds before our first confirmation. (30 minutes) +const DEFAULT_CONFIRMATION_DELAY = 1800 + +interface CreateChannelOptions { + targetTime?: number, + privateChan?: boolean +} + +function isPendingUpdateResponse (res: OpenChannelResponse): res is PendingUpdateResponse { + return Object.keys(res).includes('chanPending') +} + +function isChannelOpenUpdateResponse (res: OpenChannelResponse): res is ChannelOpenResponse { + return Object.keys(res).includes('chanOpen') +} + +export async function createChannel (paymentChannelNetworkAddress: string, fundingAmount: number, { + targetTime = DEFAULT_CONFIRMATION_DELAY, + privateChan = false +}: CreateChannelOptions, +{ client, logger }: LndActionOptions): Promise { + const targetConf = Math.max(Math.floor(targetTime / SECONDS_PER_BLOCK), 1) + const { publicKey, host } = parseAddress(paymentChannelNetworkAddress) + const loggablePublicKey = loggablePubKey(publicKey) + + logger.debug(`Attempting to create a channel with ${loggablePublicKey}`) + + if (host) { + await connectPeer(publicKey, host, { client, logger }) + } else { + logger.debug(`Skipping connect peer. Host is missing for pubkey ${loggablePublicKey}`) + } + + logger.debug(`Successfully connected to peer: ${loggablePublicKey}`) + + return new Promise((resolve, reject) => { + try { + const call = client.openChannel({ + nodePubkey: Buffer.from(publicKey, 'hex'), + localFundingAmount: fundingAmount, + targetConf, + private: privateChan + }) + + call.on('data', data => { + if (isPendingUpdateResponse(data)) { + return resolve() + } + + if (isChannelOpenUpdateResponse(data)) { + return resolve() + } + }) + + call.on('error', reject) + } catch (e) { + reject(e) + } + }) +} diff --git a/src/global-shared/lnd-engine/deadline.ts b/src/global-shared/lnd-engine/deadline.ts new file mode 100644 index 0000000..2ed2ae9 --- /dev/null +++ b/src/global-shared/lnd-engine/deadline.ts @@ -0,0 +1,3 @@ +export function deadline (duration = 30): number { + return new Date().setSeconds(new Date().getSeconds() + duration) +} diff --git a/src/global-shared/lnd-engine/index.ts b/src/global-shared/lnd-engine/index.ts index 760e10a..be409b6 100644 --- a/src/global-shared/lnd-engine/index.ts +++ b/src/global-shared/lnd-engine/index.ts @@ -1 +1,3 @@ +export { deadline } from './deadline' export { payInvoice } from './pay-invoice' +export { SECONDS_PER_BLOCK } from './config' diff --git a/src/global-shared/lnd-engine/pay-invoice.ts b/src/global-shared/lnd-engine/pay-invoice.ts index b4a97d6..11f869a 100644 --- a/src/global-shared/lnd-engine/pay-invoice.ts +++ b/src/global-shared/lnd-engine/pay-invoice.ts @@ -1,6 +1,5 @@ -import { LndEngineClient } from 'lnd-engine' -import { LoggerInterface } from '../logger' import { SendPaymentState } from '../types/lnd-engine/client/router' +import { LndActionOptions } from '../types/lnd-engine/client' // Default value of invoice timeout taken from the original `sendPayment` Lightning rpc // which is 60 seconds. @@ -10,11 +9,6 @@ const INVOICE_TIMEOUT_IN_SECONDS = 60 // value for an `unlimited` fee limit from LND const INVOICE_FEE_LIMIT = '9223372036854775807' -interface LndActionOptions { - client: LndEngineClient, - logger: LoggerInterface -} - export function payInvoice (paymentRequest: string, { client, logger }: LndActionOptions): Promise { return new Promise((resolve, reject) => { logger.debug('Attempting to pay invoice') diff --git a/src/global-shared/lnd-engine/utils/index.ts b/src/global-shared/lnd-engine/utils/index.ts new file mode 100644 index 0000000..a871e76 --- /dev/null +++ b/src/global-shared/lnd-engine/utils/index.ts @@ -0,0 +1,2 @@ +export { parse } from './network-address-formatter' +export { loggablePubKey } from './loggable-pubkey' diff --git a/src/global-shared/lnd-engine/utils/loggable-pubkey.ts b/src/global-shared/lnd-engine/utils/loggable-pubkey.ts new file mode 100644 index 0000000..1599ed5 --- /dev/null +++ b/src/global-shared/lnd-engine/utils/loggable-pubkey.ts @@ -0,0 +1,5 @@ +import { Nullable } from '../../types' + +export function loggablePubKey (pubkey: Nullable): Nullable { + return pubkey ? `${pubkey.slice(0, 15)}...` : null +} diff --git a/src/global-shared/lnd-engine/utils/network-address-formatter.ts b/src/global-shared/lnd-engine/utils/network-address-formatter.ts new file mode 100644 index 0000000..f0c1aa6 --- /dev/null +++ b/src/global-shared/lnd-engine/utils/network-address-formatter.ts @@ -0,0 +1,17 @@ +const DELIMITER = ':' +const NETWORK_TYPE = 'bolt' + +interface ParsedAddress { + publicKey: string, + host?: string +} + +export function parse (paymentChannelNetworkAddress: string): ParsedAddress { + const [networkType, networkAddress] = paymentChannelNetworkAddress.split(DELIMITER, 2) + if (networkType !== NETWORK_TYPE) { + throw new Error(`Unable to parse address for payment channel network type of '${networkType}'`) + } + + const [publicKey, host] = networkAddress.split('@', 2) + return { publicKey, host } +} diff --git a/src/global-shared/parsers.ts b/src/global-shared/parsers.ts new file mode 100644 index 0000000..e3b8e92 --- /dev/null +++ b/src/global-shared/parsers.ts @@ -0,0 +1,9 @@ +export function parsePhoneNumber (phone: string): string { + const digits = phone.split('-').join('').split(' ').join('') + return digits.startsWith('+') ? digits : '+1' + digits +} + +export function parseSSN (ssn: string): string { + const digits = ssn.split('-').join('').split(' ').join('') + return [digits.slice(0, 3), digits.slice(3, 5), digits.slice(5)].join('-') +} diff --git a/src/global-shared/types/lnd-engine/client/index.ts b/src/global-shared/types/lnd-engine/client/index.ts index ccac68c..49f698e 100644 --- a/src/global-shared/types/lnd-engine/client/index.ts +++ b/src/global-shared/types/lnd-engine/client/index.ts @@ -1,5 +1,129 @@ import { LndRouter } from './router' +import { LndEngineClient } from 'lnd-engine' +import { LoggerInterface } from '../../../logger' + +export interface UTXO { + amountSat: string, + address: string +} + +export enum AddressType { + WITNESS_PUBKEY_HASH = 0, + NESTED_PUBKEY_HASH = 1 +} + +interface ListUnspentRequest { + minConfs: number, + maxConfs: number +} + +interface ListUnspentResponse { + utxos: UTXO[] +} + +export interface AddrToAmount { + [index: string]: string +} + +interface SendManyRequest { + AddrToAmount: AddrToAmount, + satPerByte?: string +} + +interface SendManyResponse { + txid: string +} + +interface NewAddressResponse { + address: string, + type?: AddressType +} + +interface EstimateFeeRequest { + AddrToAmount: AddrToAmount, + targetConf?: number +} + +interface EstimateFeeResposne { + feeSat: string, + feerateSatPerByte: string +} + +interface GrpcOptions { + deadline: number +} + +export interface LndActionOptions { + client: LndEngineClient, + logger: LoggerInterface +} + +interface LightningAddress { + pubkey: string, + host?: string +} + +interface ConnectPeerRequest { + addr: LightningAddress +} + +export interface GrpcError extends Error { + code: number, + details?: string +} + +interface OpenChannelRequest { + nodePubkey: Buffer, + localFundingAmount: number, + targetConf: number, + private: boolean +} + +interface PendingUpdate { + txid: Buffer, + outputIndex: number +} + +interface ChannelPoint { + fundingTxidBytes: Buffer +} + +interface ChannelOpenUpdate { + channelPoint: ChannelPoint +} + +export interface PendingUpdateResponse { + chanPending: PendingUpdate +} + +export interface ChannelOpenResponse { + chanOpen: ChannelOpenUpdate +} + +export type OpenChannelResponse = PendingUpdateResponse | ChannelOpenResponse + +interface OpenChannelResponseCall { + on(event: 'data', listener: (chunk: OpenChannelResponse) => void): this, + on(event: 'status', listener: (chunk: unknown) => void): this, + on(event: 'error', listener: (chunk: Error) => void): this, + end (): this +} + +interface SignMessageRequest { + msg: Buffer +} + +interface SignMessageResponse { + signature: string +} export interface Client { - router: LndRouter + router: LndRouter, + listUnspent: (req: ListUnspentRequest, opts: GrpcOptions, cb: (err: GrpcError, res: ListUnspentResponse) => void) => void, + sendMany: (req: SendManyRequest, opts: GrpcOptions, cb: (err: GrpcError, res: SendManyResponse) => void) => void, + newAddress: (req: {}, opts: GrpcOptions, cb: (err: GrpcError, res: NewAddressResponse) => void) => void, + estimateFee: (req: EstimateFeeRequest, opts: GrpcOptions, cb: (err: GrpcError, res: EstimateFeeResposne) => void) => void, + connectPeer: (req: ConnectPeerRequest, opts: GrpcOptions, cb: (err: GrpcError, res: {}) => void) => void, + openChannel (req: OpenChannelRequest): OpenChannelResponseCall, + signMessage: (req: SignMessageRequest, opts: GrpcOptions, cb: (err: GrpcError, res: SignMessageResponse) => void) => void } diff --git a/src/global-shared/types/server.ts b/src/global-shared/types/server.ts index 8a633a6..d88c53b 100644 --- a/src/global-shared/types/server.ts +++ b/src/global-shared/types/server.ts @@ -70,3 +70,9 @@ export interface VerifyPhoneResponse { export interface JurisdictionWhitelistResponse { regions: string[] } + +export interface ProofOfKeysResponse { + publicId: string, + message: string, + signature: string +} diff --git a/src/global-shared/util.ts b/src/global-shared/util.ts index 08428b4..49be759 100644 --- a/src/global-shared/util.ts +++ b/src/global-shared/util.ts @@ -2,7 +2,7 @@ import { createHash } from 'crypto' import { SwapHash, SwapPreimage } from './types' export function fail (error: Error): void { - console.log(error) + console.error(error) process.exit(1) } diff --git a/src/global-shared/validation.ts b/src/global-shared/validation.ts index 3f7f08d..8b25a4f 100644 --- a/src/global-shared/validation.ts +++ b/src/global-shared/validation.ts @@ -25,6 +25,12 @@ export function isValidSSN (ssn: string): boolean { if (area.length !== 3 || group.length !== 2 || serial.length !== 4) { return false } + if (area === '000' || group === '00' || serial === '0000') { + return false + } + if (area === '666' || area[0] === '9') { + return false + } return true } @@ -38,7 +44,7 @@ export function isValidBirthdate (birthdate: string): boolean { if (!isDigits(year) || !isDigits(month) || !isDigits(day)) { return false } - const oldestYear = 1850 // oldest birth year that Cognito allows + const oldestYear = 1850 const currentYear = (new Date()).getFullYear() if (parseInt(year, 10) < oldestYear || parseInt(year, 10) > currentYear) { return false @@ -52,12 +58,16 @@ export function isValidBirthdate (birthdate: string): boolean { return true } +export function isValidPostalCode (postalCode: string): boolean { + return isDigits(postalCode) && postalCode.length === 5 +} + export function isValidAddress (address: Address): boolean { if (address.street.length === 0 || address.city.length === 0 || address.state.length === 0 || address.country.length === 0) { return false } - if (!isDigits(address.postalCode) || address.postalCode.length !== 5) { + if (!isValidPostalCode(address.postalCode)) { return false } return true diff --git a/src/node/content-security-policies.ts b/src/node/content-security-policies.ts index f0c59d0..761fc12 100644 --- a/src/node/content-security-policies.ts +++ b/src/node/content-security-policies.ts @@ -14,7 +14,16 @@ const CONNECT_SRC_EXT = IS_PRODUCTION ? '' : `localhost:* ws://localhost:*` // https://docs.helpscout.com/article/815-csp-settings-for-beacon // Beacon removed rules (these aren't necessary for the contact form): // connect-src: wss://*.pusher.com *.sumologic.com sentry.io -const APP_CONTENT_SECURITY_POLICY = `connect-src https://ipapi.co https://hooks.zapier.com https://beaconapi.helpscout.net https://chatapi.helpscout.net https://d3hb14vkzrxvla.cloudfront.net ${CONNECT_SRC_EXT}; style-src 'unsafe-inline' https://fonts.googleapis.com https://beacon-v2.helpscout.net https://djtflbt20bdde.cloudfront.net; font-src https://fonts.gstatic.com; base-uri https://docs.helpscout.com; frame-src https://beacon-v2.helpscout.net https://verify.berbix.com; object-src https://beacon-v2.helpscout.net; img-src https://d33v4339jhl8k0.cloudfront.net ${IMG_SRC_EXT}; media-src https://beacon-v2.helpscout.net; script-src 'self' https://beacon-v2.helpscout.net https://d12wqas9hcki3z.cloudfront.net https://d33v4339jhl8k0.cloudfront.net ${SCRIPT_SRC_EXT}` +const APP_CONTENT_SECURITY_POLICY = ` + connect-src https://ipapi.co https://hooks.zapier.com https://beaconapi.helpscout.net https://chatapi.helpscout.net https://d3hb14vkzrxvla.cloudfront.net ${CONNECT_SRC_EXT}; + style-src 'unsafe-inline' https://fonts.googleapis.com https://beacon-v2.helpscout.net https://djtflbt20bdde.cloudfront.net; + font-src https://fonts.gstatic.com; + base-uri https://docs.helpscout.com; + frame-src https://beacon-v2.helpscout.net https://verify.berbix.com; + object-src https://beacon-v2.helpscout.net; + img-src https://d33v4339jhl8k0.cloudfront.net ${IMG_SRC_EXT}; + media-src https://beacon-v2.helpscout.net; + script-src 'self' https://beacon-v2.helpscout.net https://d12wqas9hcki3z.cloudfront.net https://d33v4339jhl8k0.cloudfront.net ${SCRIPT_SRC_EXT}` const ANCHOR_PROD_WHITELIST = 'production.plaid.com:*' const ANCHOR_DEV_WHITELIST = 'sandbox.plaid.com:* development.plaid.com:*' diff --git a/src/node/data/events.ts b/src/node/data/events.ts new file mode 100644 index 0000000..3a1edb8 --- /dev/null +++ b/src/node/data/events.ts @@ -0,0 +1,25 @@ +import { Database } from 'better-sqlite3' + +const PROOF_OF_KEYS_EVENT = 'shownProofOfKeys' + +export function hasShownProofOfKeys (db: Database): boolean { + const statement = db.prepare(` + SELECT COUNT(id) + FROM events + WHERE type = @eventType + `) + + return Boolean(statement.pluck().get({ eventType: PROOF_OF_KEYS_EVENT })) +} + +export function markProofOfKeysShown (db: Database): void { + const statement = db.prepare(` + INSERT INTO events ( + type + ) VALUES ( + @eventType + ) + `) + + statement.run({ eventType: PROOF_OF_KEYS_EVENT }) +} diff --git a/src/node/data/index.ts b/src/node/data/index.ts index bd4bfe4..0959702 100644 --- a/src/node/data/index.ts +++ b/src/node/data/index.ts @@ -8,6 +8,10 @@ import { failTrade, updater as tradeUpdater } from './trades' +import { + markProofOfKeysShown, + hasShownProofOfKeys +} from './events' export { initialize, @@ -18,5 +22,7 @@ export { getPendingTrades, completeTrade, failTrade, - tradeUpdater + tradeUpdater, + markProofOfKeysShown, + hasShownProofOfKeys } diff --git a/src/node/data/migrations/0002-create-events-table.sql b/src/node/data/migrations/0002-create-events-table.sql new file mode 100644 index 0000000..a5abdb6 --- /dev/null +++ b/src/node/data/migrations/0002-create-events-table.sql @@ -0,0 +1,11 @@ +CREATE TABLE eventTypes ( + name VARCHAR(100) PRIMARY KEY NOT NULL +); + +INSERT INTO eventTypes(name) VALUES('shownProofOfKeys'); + +CREATE TABLE events ( + id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + type VARCHAR(100) NOT NULL REFERENCES eventTypes(name), + timestamp DATETIME NOT NULL DEFAULT current_timestamp +); diff --git a/src/node/data/migrations/index.ts b/src/node/data/migrations/index.ts index 72a0ace..10235a6 100644 --- a/src/node/data/migrations/index.ts +++ b/src/node/data/migrations/index.ts @@ -2,7 +2,8 @@ import { join as pathJoin } from 'path' import { readdirSync } from 'fs' const migrationFilePaths = [ - pathJoin(__dirname, '0001-create-trades-table.sql') + pathJoin(__dirname, '0001-create-trades-table.sql'), + pathJoin(__dirname, '0002-create-events-table.sql') ] const version = migrationFilePaths.length diff --git a/src/node/data/trades.ts b/src/node/data/trades.ts index 23cd047..820da11 100644 --- a/src/node/data/trades.ts +++ b/src/node/data/trades.ts @@ -155,7 +155,7 @@ WHERE id = @id updater.emit('update', id) } -export function getTrades (db: Database, since = new Date(0), limit = 100): Trade[] { +export function getTrades (db: Database, olderThanTradeId = Number.MAX_SAFE_INTEGER, limit = 100): Trade[] { const statement = db.prepare(` SELECT id, @@ -171,14 +171,12 @@ SELECT preimage, failureCode FROM trades -WHERE startTime >= datetime(@sinceTimestamp, 'unixepoch', 'localtime') -ORDER BY datetime(startTime) DESC +WHERE id < @olderThanTradeId +ORDER BY id DESC LIMIT @limit `) - const sinceTimestamp = Math.round(since.getTime() / 1000) - - const dbTrades = statement.all({ sinceTimestamp, limit }) + const dbTrades = statement.all({ olderThanTradeId, limit }) return dbTrades.map(deserializeTrade) } diff --git a/src/node/router.ts b/src/node/router.ts index c65cf10..a2bcd55 100644 --- a/src/node/router.ts +++ b/src/node/router.ts @@ -132,11 +132,14 @@ export class Router { listen('getBalance', (asset: string) => this.getBalance(valueToAsset(asset))) listen('openLink', ({ link }: { link: string}) => openLink(link)) listen('trade:execute', (quote: Quote) => executeTrade(this.db, this.engines, quote)) - listen('trade:getTrades', () => store.getTrades(this.db)) + listen('trade:getTrades', ({ limit, olderThanTradeId }: { limit: number, olderThanTradeId?: number }) => store.getTrades(this.db, olderThanTradeId, limit)) + listen('trade:getTrade', ({ id }: { id: number }) => store.getTrade(this.db, id)) listen('auth:getAuth', () => getAuth()) listen('anchor:startDeposit', () => this.anchorClient.startDeposit()) listenSync('getWebviewPreloadPath', () => path.join(__dirname, 'webview-preload.js')) listen('ntp:getTime', () => getNetworkTime()) + listen('pok:hasShown', () => store.hasShownProofOfKeys(this.db)) + listen('pok:markShown', () => store.markProofOfKeysShown(this.db)) } close (): void { diff --git a/src/node/util.ts b/src/node/util.ts index 65ab286..41e2da0 100644 --- a/src/node/util.ts +++ b/src/node/util.ts @@ -4,13 +4,19 @@ import { getAuth } from './auth' import { serverRequest as baseRequest } from '../common/utils' import { UnknownJSON } from '../global-shared/fetch-json' import { shell } from 'electron' -import { IS_TEST } from '../common/config' +import { IS_TEST, IS_PRODUCTION } from '../common/config' export function serverRequest (path: string, data: object = {}): Promise { return baseRequest(path, data, getAuth) } // TODO: make a whitelist of links and use that instead of just enforcing https/mailto +const PROTOCOL_WHITELIST = ['https:', 'mailto:'] + +if (!IS_PRODUCTION) { + PROTOCOL_WHITELIST.push('http:') +} + export function openLink (link: string): void { const url = new URL(link) @@ -19,7 +25,7 @@ export function openLink (link: string): void { return } - if (url.protocol === 'https:' || url.protocol === 'mailto:') { + if (PROTOCOL_WHITELIST.includes(url.protocol)) { shell.openExternal(link) } else { logger.warn(`tried to open insecure link: ${link}`) diff --git a/src/web/custom.d.ts b/src/web/custom.d.ts index 7b08fe9..9143a76 100644 --- a/src/web/custom.d.ts +++ b/src/web/custom.d.ts @@ -11,6 +11,11 @@ declare module '*.png' { export default pngSrc } +declare module '*.jpg' { + const jpgSrc: string + export default jpgSrc +} + declare module '*.txt' { const contents: string export default contents diff --git a/src/web/domain/history.ts b/src/web/domain/history.ts index f39cb8e..df530d7 100644 --- a/src/web/domain/history.ts +++ b/src/web/domain/history.ts @@ -1,20 +1,38 @@ -import { getTrades } from './main-request' -import { Trade } from '../../common/types' +import { getTrades, getTrade } from './main-request' +import { Trade, TradeStatus } from '../../common/types' import { EventEmitter } from 'events' import { ipcRenderer } from '../electron' -export let trades: Trade[] = [] +export const trades: Map = new Map() export const updater = new EventEmitter() -async function updateTrades (): Promise { - trades = await getTrades() +export let canLoadMore = false + +const TRADE_LIMIT = 100 + +// Returns a boolean indicating whether there additional trades that +// can be loaded. +export async function loadTrades (olderThanTradeId?: number): Promise { + const tradesArr = await getTrades(TRADE_LIMIT, olderThanTradeId) + tradesArr.forEach(trade => trades.set(trade.id, trade)) + + canLoadMore = tradesArr.length === TRADE_LIMIT + // the emit needs to go after setting `canLoadMore` so consumers + // can get the updated state when the updated trades go out updater.emit('update', trades) + return canLoadMore } async function subscribeTrades (): Promise { - await updateTrades() - ipcRenderer.on('tradeUpdate', (_event: Event, _id: number) => updateTrades()) + await loadTrades() + ipcRenderer.on('tradeUpdate', async (_event: Event, id: number): Promise => { + const trade = await getTrade(id) + trades.set(trade.id, trade) + updater.emit('update', trades) + const tradeStatusName = TradeStatus[trade.status] + updater.emit(`trade:${tradeStatusName}`, trade) + }) } subscribeTrades() diff --git a/src/web/domain/main-request.ts b/src/web/domain/main-request.ts index 5c08f31..8441f4e 100644 --- a/src/web/domain/main-request.ts +++ b/src/web/domain/main-request.ts @@ -61,8 +61,12 @@ function deserializeTrade (wireTrade: UnknownObject): Trade { } } -export async function getTrades (): Promise { - return (await mainRequest('trade:getTrades') as UnknownObject[]).map(deserializeTrade) +export async function getTrades (limit: number, olderThanTradeId?: number): Promise { + return (await mainRequest('trade:getTrades', { limit, olderThanTradeId }) as UnknownObject[]).map(deserializeTrade) +} + +export async function getTrade (id: number): Promise { + return deserializeTrade(await mainRequest('trade:getTrade', { id }) as UnknownObject) } export async function startDeposit (): Promise { @@ -89,4 +93,12 @@ export function handleLightningPaymentUri (fn: paymentUriHandler): void { }) } +export async function hasShownProofOfKeys (): Promise { + return await mainRequest('pok:hasShown') as boolean +} + +export async function markProofOfKeysShown (): Promise { + await mainRequest('pok:markShown') +} + export default mainRequest diff --git a/src/web/domain/proof-of-keys.ts b/src/web/domain/proof-of-keys.ts new file mode 100644 index 0000000..9fe151e --- /dev/null +++ b/src/web/domain/proof-of-keys.ts @@ -0,0 +1,55 @@ +import { markProofOfKeysShown, hasShownProofOfKeys } from './main-request' +import logger from '../../global-shared/logger' +import { getProofOfKeys } from './server' +import { updater as historyUpdater } from './history' +import { TradeStatus } from '../../common/types' +import { EventEmitter } from 'events' + +export let showProofOfKeys = false + +export const updater = new EventEmitter() + +const HISTORY_EVENT = `trade:${TradeStatus[TradeStatus.COMPLETE]}` +const PROOF_DATE = new Date('1/3/2020') + +export async function doneWithProofOfKeys (): Promise { + showProofOfKeys = false + updater.emit('update', showProofOfKeys) + try { + await markProofOfKeysShown() + } catch (e) { + logger.error(`Failed to mark proof of keys done: ${e}`) + } +} + +async function onTradeComplete (): Promise { + if (new Date() >= PROOF_DATE) { + try { + const { publicId } = await getProofOfKeys() + // only show the update if the proof generated successfully + if (publicId) { + showProofOfKeys = true + updater.emit('update', showProofOfKeys) + historyUpdater.removeListener(HISTORY_EVENT, onTradeComplete) + } else { + logger.error('Proof of keys ID was unavailable, skipping dialog') + } + } catch (e) { + logger.error(`Failed to show proof of keys on trade complete: ${e}`) + } + } +} + +async function subscribeProofOfKeys (): Promise { + try { + if (await hasShownProofOfKeys()) { + logger.debug('Skipping proof of keys listener setup') + } else { + historyUpdater.on(HISTORY_EVENT, onTradeComplete) + } + } catch (e) { + logger.error(`Failed to initialize proof of keys: ${e}`) + } +} + +subscribeProofOfKeys() diff --git a/src/web/domain/server.ts b/src/web/domain/server.ts index 0812a9d..a8e1725 100644 --- a/src/web/domain/server.ts +++ b/src/web/domain/server.ts @@ -9,7 +9,8 @@ import { JurisdictionWhitelistResponse, KYCUploadRequest, KYCUploadResponse, - VerifyPhoneResponse + VerifyPhoneResponse, + ProofOfKeysResponse } from '../../global-shared/types/server' import { UnknownJSON, isUnknownJSON } from '../../global-shared/fetch-json' @@ -93,3 +94,9 @@ export async function getApprovedJurisdictions (): Promise { + const res = await serverRequest(API_ENDPOINTS.GET_PROOF) + + return res as unknown as ProofOfKeysResponse +} diff --git a/src/web/ui/App.css b/src/web/ui/App.css index c4b8fab..ef0e740 100644 --- a/src/web/ui/App.css +++ b/src/web/ui/App.css @@ -1,3 +1,5 @@ +@import url("https://fonts.googleapis.com/css?family=Encode+Sans&text=₿"); + html { height: 100%; } @@ -17,7 +19,9 @@ body.sparkswap { to top right, rgba(27, 34, 42, 1), rgba(41, 52, 59, 1) ); - background-attachment: fixed + background-attachment: fixed; + font-family: -apple-system, "BlinkMacSystemFont", "Segoe UI", "Roboto", "Oxygen", + "Ubuntu", "Cantarell", "Open Sans", "Helvetica Neue", "Icons16", sans-serif, "Encode Sans"; } .App { @@ -161,6 +165,13 @@ body.sparkswap { /* Dialogs */ +.bp3-portal { + z-index: 1; +} +.bp3-portal.portal-behind { + z-index: 0; +} + .bp3-dark.sparkswap .bp3-dialog { width: 360px; background-color: rgba(16, 22, 26, 0.85); diff --git a/src/web/ui/App.tsx b/src/web/ui/App.tsx index 34d4fc0..8f83570 100644 --- a/src/web/ui/App.tsx +++ b/src/web/ui/App.tsx @@ -22,6 +22,7 @@ import { getAuth, openLinkInBrowser, startDeposit } from '../domain/main-request import { ReviewStatus, URL } from '../../global-shared/types' import { ReactComponent as Logo } from './assets/icon-dark.svg' import { Button, IActionProps } from '@blueprintjs/core' +import { ProofOfKeysDialog } from './proof-of-keys-dialog' interface OnboardingStep { stage: OnboardingStage, @@ -205,6 +206,7 @@ class App extends React.Component<{}, AppState> { + {
{this.renderBalance(Asset.BTC)} - + {this.renderConverted(Asset.BTC)}
diff --git a/src/web/ui/History.tsx b/src/web/ui/History.tsx index 9e87fe9..fe937b7 100644 --- a/src/web/ui/History.tsx +++ b/src/web/ui/History.tsx @@ -1,9 +1,15 @@ import React, { ReactNode } from 'react' import './History.css' -import { HTMLTable, H4, Spinner } from '@blueprintjs/core' +import { HTMLTable, H4, Spinner, Button } from '@blueprintjs/core' import { Trade, TradeStatus } from '../../common/types' import { formatDate, formatAmount } from './formatters' -import { trades, updater as tradeUpdater } from '../domain/history' +import { showErrorToast } from './AppToaster' +import { + trades, + updater as tradeUpdater, + loadTrades, + canLoadMore as canLoadMoreTrades +} from '../domain/history' interface HistoryRowProps { trade: Trade @@ -58,19 +64,66 @@ class HistoryRow extends React.PureComponent { } interface HistoryState { - trades: Trade[] + trades: Trade[], + loading: boolean, + canLoadMore: boolean +} + +function mapToArr (tradesMap: Map): Trade[] { + return Array.from(tradesMap.values()).sort((a, b) => { + return b.id - a.id + }) } class History extends React.PureComponent<{}, HistoryState> { constructor (props: object) { super(props) + this.state = { - trades + trades: mapToArr(trades), + loading: false, + canLoadMore: canLoadMoreTrades } } componentDidMount (): void { - tradeUpdater.on('update', trades => this.setState({ trades })) + tradeUpdater.on('update', trades => { + this.setState({ + trades: mapToArr(trades), + canLoadMore: canLoadMoreTrades + }) + }) + } + + handleLoadMore = async (): Promise => { + this.setState({ loading: true }) + const { trades } = this.state + try { + const lastTradeId = trades.length ? trades[trades.length - 1].id : undefined + const canLoadMore = await loadTrades(lastTradeId) + this.setState({ canLoadMore }) + } catch (e) { + showErrorToast(`Error while loading additional trades: ${e.message}`) + } finally { + this.setState({ loading: false }) + } + } + + renderLoadMore (): React.ReactNode { + if (!this.state.canLoadMore) { + return + } + + return ( + + ) } render (): React.ReactNode { @@ -93,6 +146,7 @@ class History extends React.PureComponent<{}, HistoryState> { {trades.map((trade) => )} + {this.renderLoadMore()}
diff --git a/src/web/ui/PayInvoice.tsx b/src/web/ui/PayInvoice.tsx index 608cad5..c819ef3 100644 --- a/src/web/ui/PayInvoice.tsx +++ b/src/web/ui/PayInvoice.tsx @@ -10,8 +10,13 @@ import logger from '../../global-shared/logger' import { Asset, Unit, Amount, Nullable } from '../../global-shared/types' import { SpinnerSuccess } from './components' import { formatAmount, formatAsset } from './formatters' +import { isUSDXSufficient } from '../domain/balance' import './PayInvoice.css' +interface PayInvoiceProps { + onDeposit: Function +} + interface PayInvoiceState { isDialogOpen: boolean, paymentRequest: string, @@ -36,8 +41,8 @@ const initialState: PayInvoiceState = { secondsRemaining: 0 } -class PayInvoice extends React.Component<{}, PayInvoiceState> { - constructor (props: {}) { +class PayInvoice extends React.Component { + constructor (props: PayInvoiceProps) { super(props) this.state = initialState @@ -172,6 +177,21 @@ class PayInvoice extends React.Component<{}, PayInvoiceState> { if (this.state.quote === null) { throw new Error(`Quote not loaded`) } + const usdxAmount = this.state.quote.sourceAmount.asset === Asset.USDX + ? this.state.quote.sourceAmount + : this.state.quote.destinationAmount + + if (!isUSDXSufficient(usdxAmount.value)) { + showErrorToast('Insufficient USD', { + onClick: () => this.props.onDeposit(), + text: 'Deposit' + }) + this.setState({ + isPaying: false + }) + return + } + await executeTrade(this.state.quote) } diff --git a/src/web/ui/Trade.tsx b/src/web/ui/Trade.tsx index 869f02d..51e64ef 100644 --- a/src/web/ui/Trade.tsx +++ b/src/web/ui/Trade.tsx @@ -307,7 +307,7 @@ class Trade extends React.Component { }) this.countdown() } catch (e) { - if (e.statusCode === 403) { + if (e.status === 403) { showSupportToast('Your account must be approved prior to trading') } else { showSupportToast('Failed to get price: ' + e.message) diff --git a/src/web/ui/assets/sparkswap-tile.jpg b/src/web/ui/assets/sparkswap-tile.jpg new file mode 100644 index 0000000..036f8f8 Binary files /dev/null and b/src/web/ui/assets/sparkswap-tile.jpg differ diff --git a/src/web/ui/assets/twitter.svg b/src/web/ui/assets/twitter.svg new file mode 100755 index 0000000..f1ddcdc --- /dev/null +++ b/src/web/ui/assets/twitter.svg @@ -0,0 +1,58 @@ + + + +image/svg+xml \ No newline at end of file diff --git a/src/web/ui/components/ExternalSource.tsx b/src/web/ui/components/ExternalSource.tsx index f628469..ec9a030 100644 --- a/src/web/ui/components/ExternalSource.tsx +++ b/src/web/ui/components/ExternalSource.tsx @@ -17,6 +17,7 @@ export class ExternalButton extends React.Component { return (