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 @@
+
+
+
+
\ 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 (
+
+ {this.renderFooter()}
+
+ )
+ }
+}
diff --git a/src/web/ui/proof-of-keys.tsx b/src/web/ui/proof-of-keys.tsx
new file mode 100644
index 0000000..4cd8528
--- /dev/null
+++ b/src/web/ui/proof-of-keys.tsx
@@ -0,0 +1,48 @@
+import React, { ReactNode } from 'react'
+import { Dialog } from '@blueprintjs/core'
+import { showProofOfKeys, updater, doneWithProofOfKeys } from '../domain/proof-of-keys'
+
+interface ProofOfKeysDialogState {
+ isOpen: boolean
+}
+
+export class ProofOfKeysDialog extends React.Component<{}, ProofOfKeysDialogState> {
+ constructor (props: {}) {
+ super(props)
+ this.state = {
+ isOpen: showProofOfKeys
+ }
+ }
+
+ onUpdate = (isOpen: boolean): void => {
+ this.setState({ isOpen })
+ }
+
+ componentDidMount (): void {
+ updater.on('update', this.onUpdate)
+ }
+
+ componentWillUnmount (): void {
+ updater.removeListener('update', this.onUpdate)
+ }
+
+ handleClose = (): void => {
+ // no need to await, if we don't commit to long term memory
+ // the worst that happens is we show the pop-up again
+ doneWithProofOfKeys()
+ // we should close automatically, but we close immediately
+ // so as to get the immediate feedback
+ this.setState({ isOpen: false })
+ }
+
+ render (): ReactNode {
+ return (
+
+ )
+ }
+}