From f3c471d36143715e574032556e248ea282b7372a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Vitor=20de=20Lima=20Matos?= Date: Wed, 7 Jul 2021 21:39:38 -0300 Subject: [PATCH 01/11] sdk: add eciesjs & @ethersproject/hash dependencies They're needed for encrypted secret handling and public key calculation. --- raiden-ts/package.json | 2 ++ yarn.lock | 23 ++++++++++++++++++++++- 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/raiden-ts/package.json b/raiden-ts/package.json index 73edd70820..e59debb579 100644 --- a/raiden-ts/package.json +++ b/raiden-ts/package.json @@ -82,6 +82,7 @@ "@ethersproject/bytes": "^5.4.0", "@ethersproject/constants": "^5.4.0", "@ethersproject/contracts": "^5.4.0", + "@ethersproject/hash": "^5.4.0", "@ethersproject/keccak256": "^5.4.0", "@ethersproject/networks": "^5.4.1", "@ethersproject/properties": "^5.4.0", @@ -96,6 +97,7 @@ "@ethersproject/wallet": "^5.4.0", "abort-controller": "^3.0.0", "decimal.js": "^10.3.1", + "eciesjs": "^0.3.11", "ethers": "^5.4.1", "fp-ts": "^2.10.5", "io-ts": "^2.2.16", diff --git a/yarn.lock b/yarn.lock index 4f68aba58b..da0d18ea9f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3159,6 +3159,13 @@ dependencies: "@types/node" "*" +"@types/secp256k1@^4.0.2": + version "4.0.3" + resolved "https://registry.yarnpkg.com/@types/secp256k1/-/secp256k1-4.0.3.tgz#1b8e55d8e00f08ee7220b4d59a6abe89c37a901c" + integrity sha512-Da66lEIFeIz9ltsdMZcpQvmrmmoqrfju8pm1BH8WbYjZSwUgCwXLb9C+9XYogwBITnbsSaMdVPb2ekf7TV+03w== + dependencies: + "@types/node" "*" + "@types/serve-static@*": version "1.13.9" resolved "https://registry.yarnpkg.com/@types/serve-static/-/serve-static-1.13.9.tgz#aacf28a85a05ee29a11fb7c3ead935ac56f33e4e" @@ -7973,6 +7980,15 @@ ecc-jsbn@~0.1.1: jsbn "~0.1.0" safer-buffer "^2.1.0" +eciesjs@^0.3.11: + version "0.3.11" + resolved "https://registry.yarnpkg.com/eciesjs/-/eciesjs-0.3.11.tgz#8efea2735f23d4effbda07f1bee40eff5a2ce491" + integrity sha512-WMyCAhS45hJdSzk2TZMYj9vo8+XjcsvnTG63MsIBmKgZbtTjViVSu201YmgfrjMuPZNm7gI7sJ8bkBR6ejN21A== + dependencies: + "@types/secp256k1" "^4.0.2" + futoin-hkdf "^1.3.3" + secp256k1 "^4.0.2" + editorconfig@^0.15.3: version "0.15.3" resolved "https://registry.yarnpkg.com/editorconfig/-/editorconfig-0.15.3.tgz#bef84c4e75fb8dcb0ce5cee8efd51c15999befc5" @@ -9770,6 +9786,11 @@ functional-red-black-tree@^1.0.1: resolved "https://registry.yarnpkg.com/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz#1b0ab3bd553b2a0d6399d29c0e3ea0b252078327" integrity sha1-GwqzvVU7Kg1jmdKcDj6gslIHgyc= +futoin-hkdf@^1.3.3: + version "1.4.2" + resolved "https://registry.yarnpkg.com/futoin-hkdf/-/futoin-hkdf-1.4.2.tgz#fd534e848e0e50339b8bfbd81250b09cbff10ba3" + integrity sha512-2BggwLEJOTfXzKq4Tl2bIT37p0IqqKkblH4e0cMp2sXTdmwg/ADBKMxvxaEytYYcgdxgng8+acsi3WgMVUl6CQ== + gauge@~2.7.3: version "2.7.4" resolved "https://registry.yarnpkg.com/gauge/-/gauge-2.7.4.tgz#2c03405c7538c39d7eb37b317022e325fb018bf7" @@ -17063,7 +17084,7 @@ sec@^1.0.0: resolved "https://registry.yarnpkg.com/sec/-/sec-1.0.0.tgz#033d60a3ad20ecf2e00940d14f97823465774335" integrity sha1-Az1go60g7PLgCUDRT5eCNGV3QzU= -secp256k1@^4.0.1: +secp256k1@^4.0.1, secp256k1@^4.0.2: version "4.0.2" resolved "https://registry.yarnpkg.com/secp256k1/-/secp256k1-4.0.2.tgz#15dd57d0f0b9fdb54ac1fa1694f40e5e9a54f4a1" integrity sha512-UDar4sKvWAksIlfX3xIaQReADn+WFnHvbVujpcbr+9Sf/69odMwy2MUsz5CKLQgX9nsIyrjuxL2imVyoNHa3fg== From 15d6b8ae62563df1e6568f3810c58b9bd2b1f663 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Vitor=20de=20Lima=20Matos?= Date: Wed, 7 Jul 2021 21:41:23 -0300 Subject: [PATCH 02/11] sdk: add config.encryptSecret since sending encrypted secrets use only target's pubkey, it doesn't require our private key, so it can be enabled by default. target will of course only be able to act on it if they've the private key available (e.g. ether's Wallet, as default in CLI and with Raiden key) --- raiden-ts/src/config.ts | 4 +++- raiden-ts/src/helpers.ts | 48 ++++++++++++++-------------------------- raiden-ts/src/raiden.ts | 2 +- 3 files changed, 21 insertions(+), 33 deletions(-) diff --git a/raiden-ts/src/config.ts b/raiden-ts/src/config.ts index 4be8487dea..78cb0d1870 100644 --- a/raiden-ts/src/config.ts +++ b/raiden-ts/src/config.ts @@ -56,6 +56,7 @@ const RTCIceServer = t.type({ urls: t.union([t.string, t.array(t.string)]) }); * validated and decoded by [[FeeModel.decodeConfig]]. * - gasPriceFactor - Multiplier to be applied over `eth_gasPrice` for initial transactions gas prices; * 1.1 means gasPrice returned by ETH node is added of 10% for every transaction sent + * - encryptSecret - Whether to send secret encrypted to target by default on transfers * - matrixServer? - Specify a matrix server to use. * - subkey? - When using subkey, this sets the behavior when { subkey } option isn't explicitly * set in on-chain method calls. false (default) = use main key; true = use subkey @@ -93,6 +94,7 @@ export const RaidenConfig = t.readonly( autoUDCWithdraw: t.boolean, mediationFees: t.unknown, gasPriceFactor: t.number, + encryptSecret: t.boolean, }), t.partial({ matrixServer: t.string, @@ -125,7 +127,6 @@ export function makeDefaultConfig( network.chainId === 1 ? 'https://raw.githubusercontent.com/raiden-network/raiden-service-bundle/master/known_servers/known_servers-production-v1.2.0.json' : 'https://raw.githubusercontent.com/raiden-network/raiden-service-bundle/master/known_servers/known_servers-development-v1.2.0.json'; - // merge caps independently const caps = overwrites?.caps === null @@ -160,6 +161,7 @@ export function makeDefaultConfig( autoUDCWithdraw: true, mediationFees: {}, gasPriceFactor: 1.0, + encryptSecret: true, ...overwrites, caps, // merged caps overwrites 'overwrites.caps' }; diff --git a/raiden-ts/src/helpers.ts b/raiden-ts/src/helpers.ts index 529b33917b..396ae9f5a2 100644 --- a/raiden-ts/src/helpers.ts +++ b/raiden-ts/src/helpers.ts @@ -695,56 +695,42 @@ export function waitChannelSettleable$( /** * Helper function to create the RaidenEpicDeps dependencies object for Raiden Epics * - * @param signer - Signer holding raiden account connected to a JsonRpcProvider - * @param contractsInfo - Object holding deployment information from Raiden contracts on current network + * @param state - Initial/previous RaidenState + * @param config - defaultConfig overwrites * @param opts - Options - * @param opts.state - Initial/previous RaidenState + * @param opts.signer - Signer holding raiden account connected to a JsonRpcProvider + * @param opts.contractsInfo - Object holding deployment information from Raiden contracts on + * current network * @param opts.db - Database instance - * @param opts.config - defaultConfig overwrites * @param opts.main - Main account object, set when using a subkey as raiden signer - * @param opts.main.address - Address of main signer - * @param opts.main.signer - Signer instance from which the subkey used raiden account was derived * @returns Constructed epics dependencies object */ export function makeDependencies( - signer: Signer, - contractsInfo: ContractsInfo, + state: RaidenState, + config: PartialRaidenConfig | undefined, { + signer, + contractsInfo, db, - state, - config, main, - }: { - state: RaidenState; - db: RaidenDatabase; - config?: PartialRaidenConfig; - main?: { address: Address; signer: Signer }; - }, + }: Pick, ): RaidenEpicDeps { - const provider = signer.provider; assert( - provider && provider instanceof JsonRpcProvider && provider.network, + signer.provider && signer.provider instanceof JsonRpcProvider && signer.provider.network, 'Signer must be connected to a JsonRpcProvider', ); - const network = provider.network; const latest$ = new ReplaySubject(1); - const config$ = latest$.pipe(pluckDistinct('config')); - const matrix$ = new AsyncSubject(); - - const address = state.address; - const defaultConfig = makeDefaultConfig({ network }, config); - const log = logging.getLogger(`raiden:${address}`); return { latest$, - config$, - matrix$, - provider, - network, + config$: latest$.pipe(pluckDistinct('config')), + matrix$: new AsyncSubject(), signer, + provider: signer.provider, + network: signer.provider.network, address: state.address, - log, - defaultConfig, + log: logging.getLogger(`raiden:${state.address}`), + defaultConfig: makeDefaultConfig({ network: signer.provider.network }, config), contractsInfo, registryContract: TokenNetworkRegistry__factory.connect( contractsInfo.TokenNetworkRegistry.address, diff --git a/raiden-ts/src/raiden.ts b/raiden-ts/src/raiden.ts index 0e19dc3760..a965bce860 100644 --- a/raiden-ts/src/raiden.ts +++ b/raiden-ts/src/raiden.ts @@ -347,7 +347,7 @@ export class Raiden { ); const cleanConfig = config && decode(PartialRaidenConfig, omitBy(config, isUndefined)); - const deps = makeDependencies(signer, contractsInfo, { db, state, config: cleanConfig, main }); + const deps = makeDependencies(state, cleanConfig, { signer, contractsInfo, db, main }); return new this(state, deps) as InstanceType; } From 952f6b644a3c4cc24db9a65764c00daf196cc7cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Vitor=20de=20Lima=20Matos?= Date: Wed, 7 Jul 2021 21:42:47 -0300 Subject: [PATCH 03/11] sdk: improve utils and add PublicKey type utils's `dispatchRequestAndGetResponse` now accepts an array of AACs, and will generic on the `request` to allow multiple types to be sent in a single instantiation. --- raiden-ts/src/utils/rx.ts | 14 +++++++++----- raiden-ts/src/utils/types.ts | 4 ++++ 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/raiden-ts/src/utils/rx.ts b/raiden-ts/src/utils/rx.ts index c05f51445d..ed4201835a 100644 --- a/raiden-ts/src/utils/rx.ts +++ b/raiden-ts/src/utils/rx.ts @@ -381,7 +381,9 @@ export function catchAndLog( * ), * ) * - * @param aac - AsyncActionCreator type to wait for response + * @param aac - AsyncActionCreator type to wait for response; can be an array of action creators, + * in which case, dispatchRequest function will accept one request and return an observable + * for the corresponding response * @param project - Function to be merged to output; called with a function which allows to * dispatch requests directly to output and returns an observable which will emit the success * coming in input and complete, or error if a failure goes through @@ -398,21 +400,23 @@ export function dispatchRequestAndGetResponse< AAC extends AsyncActionCreator, R, >( - aac: AAC, + aac: AAC | AAC[], project: ( - dispatchRequest: ( - request: ActionType, - ) => Observable>, + dispatchRequest: ( + request: ActionType, + ) => Observable>, ) => ObservableInput, confirmed = false, dedupKey = (value: ActionType): unknown => value, ): OperatorFunction> { + const arr = Array.isArray(aac) ? aac : [aac]; return (input$) => defer(() => { const requestOutput$ = new Subject>(); const pending = new Map>>(); const projectOutput$ = defer(() => project((request) => { + const aac = arr.find(({ request: areq }) => areq.type === request.type)!; const key = dedupKey(request); const pending$ = pending.get(key); if (pending$) return pending$; diff --git a/raiden-ts/src/utils/types.ts b/raiden-ts/src/utils/types.ts index d8ab41a5bc..72b0420049 100644 --- a/raiden-ts/src/utils/types.ts +++ b/raiden-ts/src/utils/types.ts @@ -190,6 +190,10 @@ export type Secret = HexString<32>; export const PrivateKey = HexString(32); export type PrivateKey = HexString<32>; +// uncompressed secp256k1 public key +export const PublicKey = HexString(65); +export type PublicKey = HexString<65>; + // checksummed address brand interface export interface AddressB { readonly Address: unique symbol; From 1c0a1ca3ad454445e94c60ccb7e1573ffa588e78 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Vitor=20de=20Lima=20Matos?= Date: Wed, 7 Jul 2021 21:48:54 -0300 Subject: [PATCH 04/11] sdk: add pubkey property to matrixPresence.success action Needed in order to encrypt secrets to given peer --- raiden-ts/src/messages/utils.ts | 25 ++++++++++++++++++----- raiden-ts/src/services/utils.ts | 13 ++++++------ raiden-ts/src/transfers/utils.ts | 2 +- raiden-ts/src/transport/actions.ts | 4 ++-- raiden-ts/src/transport/epics/presence.ts | 3 ++- 5 files changed, 31 insertions(+), 16 deletions(-) diff --git a/raiden-ts/src/messages/utils.ts b/raiden-ts/src/messages/utils.ts index c80aeaac7c..592f428a9b 100644 --- a/raiden-ts/src/messages/utils.ts +++ b/raiden-ts/src/messages/utils.ts @@ -1,9 +1,12 @@ import type { Signer } from '@ethersproject/abstract-signer'; import { arrayify, concat as concatBytes, hexlify } from '@ethersproject/bytes'; import { HashZero } from '@ethersproject/constants'; +import { hashMessage } from '@ethersproject/hash'; import { keccak256 } from '@ethersproject/keccak256'; import { encode as rlpEncode } from '@ethersproject/rlp'; +import { recoverPublicKey } from '@ethersproject/signing-key'; import { toUtf8Bytes } from '@ethersproject/strings'; +import { computeAddress } from '@ethersproject/transactions'; import { verifyMessage } from '@ethersproject/wallet'; import type * as t from 'io-ts'; import { canonicalize } from 'json-canonicalize'; @@ -18,7 +21,7 @@ import type { RaidenEpicDeps } from '../types'; import { assert } from '../utils'; import { encode, jsonParse, jsonStringify } from '../utils/data'; import { ErrorCodes } from '../utils/error'; -import type { Address, Hash, HexString } from '../utils/types'; +import type { Address, Hash, HexString, PublicKey } from '../utils/types'; import { decode, Signature, Signed } from '../utils/types'; import { messageReceived } from './actions'; import type { AddressMetadata, EnvelopeMessage } from './types'; @@ -432,10 +435,22 @@ export function validateAddressMetadata( metadata: AddressMetadata | undefined, address: Address, { log }: { log: logging.Logger } = { log: logging }, -): AddressMetadata | undefined { - if (metadata && verifyMessage(metadata.user_id, metadata.displayname) === address) - return metadata; - else if (metadata) log?.warn('Invalid address metadata', { address, metadata }); +): [metadata: AddressMetadata, pubkey: PublicKey] | undefined { + if (!metadata) return; + try { + const pubkey = recoverPublicKey( + arrayify(hashMessage(metadata.user_id)), + metadata.displayname, + ) as PublicKey; + const recoveredAddress = computeAddress(pubkey) as Address; + assert(recoveredAddress === address, [ + 'Wrong signature', + { expected: address, received: recoveredAddress }, + ]); + return [metadata, pubkey]; + } catch (error) { + log?.warn('Invalid address metadata', { address, metadata, error }); + } } /** diff --git a/raiden-ts/src/services/utils.ts b/raiden-ts/src/services/utils.ts index 95569b8c02..588ad6d44e 100644 --- a/raiden-ts/src/services/utils.ts +++ b/raiden-ts/src/services/utils.ts @@ -18,7 +18,7 @@ import { encode, jsonParse } from '../utils/data'; import { assert, ErrorCodes, networkErrors, RaidenError } from '../utils/error'; import { LruCache } from '../utils/lru'; import { retryAsync$ } from '../utils/rx'; -import type { Signature, Signed } from '../utils/types'; +import type { PublicKey, Signature, Signed } from '../utils/types'; import { Address, decode, UInt } from '../utils/types'; import type { IOU, PFS } from './types'; @@ -223,20 +223,19 @@ export function getPresenceFromService$( peer: Address, pfsAddrOrUrl: string, { serviceRegistryContract }: Pick, -): Observable<{ readonly user_id: string; readonly capabilities: Caps }> { +): Observable<{ user_id: string; capabilities: Caps; pubkey: PublicKey }> { return defer(async () => pfsAddressUrl(pfsAddrOrUrl, { serviceRegistryContract })).pipe( mergeMap((url) => fromFetch(`${url}/api/v1/address/${peer}/metadata`)), mergeMap(async (res) => res.json()), map((json) => { try { const presence = decode(AddressMetadata, json); - assert(validateAddressMetadata(presence, peer), [ - 'Invalid metadata signature', - { peer, presence }, - ]); + const meta = validateAddressMetadata(presence, peer); + assert(meta, ['Invalid metadata signature', { peer, presence }]); + const [, pubkey] = meta; const capabilities = parseCaps(presence.capabilities); assert(capabilities, ['Invalid capabilities format', presence.capabilities]); - return { ...presence, capabilities }; + return { ...presence, capabilities, pubkey }; } catch (err) { try { const { errors: msg, ...details } = decode(ServiceError, json); diff --git a/raiden-ts/src/transfers/utils.ts b/raiden-ts/src/transfers/utils.ts index 6a10102b5a..a5c853c451 100644 --- a/raiden-ts/src/transfers/utils.ts +++ b/raiden-ts/src/transfers/utils.ts @@ -339,7 +339,7 @@ export function searchValidMetadata( // support address_metadata keys being both lowercase and checksummed addresses addressMetadata?.[address] ?? addressMetadata?.[address.toLowerCase()], address, - ); + )?.[0]; } /** diff --git a/raiden-ts/src/transport/actions.ts b/raiden-ts/src/transport/actions.ts index 930adcefe1..8dc44ec4dd 100644 --- a/raiden-ts/src/transport/actions.ts +++ b/raiden-ts/src/transport/actions.ts @@ -3,7 +3,7 @@ import * as t from 'io-ts'; import type { ActionType } from '../utils/actions'; import { createAction, createAsyncAction } from '../utils/actions'; -import { Address, instanceOf } from '../utils/types'; +import { Address, instanceOf, PublicKey } from '../utils/types'; import { RaidenMatrixSetup } from './state'; const NodeId = t.type({ address: Address }); @@ -25,7 +25,7 @@ export const matrixPresence = createAsyncAction( 'matrix/presence/failure', undefined, t.intersection([ - t.type({ userId: t.string, available: t.boolean, ts: t.number }), + t.type({ userId: t.string, available: t.boolean, ts: t.number, pubkey: PublicKey }), t.partial({ caps: t.record(t.string, t.any) }), ]), ); diff --git a/raiden-ts/src/transport/epics/presence.ts b/raiden-ts/src/transport/epics/presence.ts index 88d3585f26..87006d8327 100644 --- a/raiden-ts/src/transport/epics/presence.ts +++ b/raiden-ts/src/transport/epics/presence.ts @@ -69,13 +69,14 @@ function searchAddressPresence$( first(), ); }), - map(({ user_id: userId, capabilities }) => + map(({ user_id: userId, capabilities, pubkey }) => matrixPresence.success( { userId, available: true, ts: Date.now(), caps: capabilities, + pubkey, }, { address }, ), From 3a9792d33bcbd7bf7bc55b21f2e1c1789df47bb6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Vitor=20de=20Lima=20Matos?= Date: Wed, 7 Jul 2021 21:55:01 -0300 Subject: [PATCH 05/11] sdk: split Raiden.transfer logic into transferRequestResolveEpic 'transfer' method was getting too much logic. This makes the `transfer.request` action contain some intersection to tagged unions (resolved: boolean) which chooses into 2 modes: the resolved=false is handled by the new `transferRequestResolveEpic` and will call e.g. pathFind.request (previously done in `Raiden.transfer`) and after resolving everything needed, will emit `transfer.request` again with resolved=true. This allows the API's transfer.request to be way simpler. Also, `expiration` is moved to the `locked` epic, so it's more precisely calculated when the resolved action is handled. --- raiden-ts/src/raiden.ts | 87 ++++++++---------------- raiden-ts/src/transfers/actions.ts | 28 +++++--- raiden-ts/src/transfers/epics/init.ts | 76 +++++++++++++++++++-- raiden-ts/src/transfers/epics/locked.ts | 31 ++++++--- raiden-ts/src/transfers/mediate/epics.ts | 23 +++++-- 5 files changed, 157 insertions(+), 88 deletions(-) diff --git a/raiden-ts/src/raiden.ts b/raiden-ts/src/raiden.ts index a965bce860..52fd1bc033 100644 --- a/raiden-ts/src/raiden.ts +++ b/raiden-ts/src/raiden.ts @@ -18,7 +18,7 @@ import { createLogger } from 'redux-logger'; import type { EpicMiddleware } from 'redux-observable'; import { createEpicMiddleware } from 'redux-observable'; import type { Observable } from 'rxjs'; -import { defer, EMPTY, from, merge, of, throwError } from 'rxjs'; +import { EMPTY, from, of, throwError } from 'rxjs'; import { fromFetch } from 'rxjs/fetch'; import { catchError, @@ -83,7 +83,6 @@ import { getSecrethash, makePaymentId, makeSecret, - metadataFromPaths, raidenTransfer, transferKey, transferKeyToMeta, @@ -92,7 +91,7 @@ import { matrixPresence } from './transport/actions'; import type { ContractsInfo, OnChange, RaidenEpicDeps } from './types'; import { EventTypes } from './types'; import { assert } from './utils'; -import { asyncActionToPromise, isActionOf, isResponseOf } from './utils/actions'; +import { asyncActionToPromise, isActionOf } from './utils/actions'; import { jsonParse } from './utils/data'; import { ErrorCodes, RaidenError } from './utils/error'; import { getLogsByChunk$ } from './utils/ethers'; @@ -854,6 +853,8 @@ export class Raiden { * disabled (null), use it if set or if undefined (auto mode), fetches the best * PFS from ServiceRegistry and automatically fetch routes from it. * @param options.lockTimeout - Specify a lock timeout for transfer; default is 2 * revealTimeout + * @param options.encryptSecret - Whether to force encrypting the secret or not, + * if target supports it * @returns A promise to transfer's unique key (id) when it's accepted */ public async transfer( @@ -867,6 +868,7 @@ export class Raiden { paths?: RaidenPaths; pfs?: RaidenPFS; lockTimeout?: number; + encryptSecret?: boolean; } = {}, ): Promise { assert(Address.is(token), [ErrorCodes.DTA_INVALID_ADDRESS, { token }], this.log.info); @@ -885,10 +887,6 @@ export class Raiden { const pfs = !options.pfs ? undefined : decode(PFS, options.pfs, ErrorCodes.DTA_INVALID_PFS, this.log.info); - // if undefined, default expiration is calculated at locked's [[makeAndSignTransfer$]] - const expiration = !options.lockTimeout - ? undefined - : this.state.blockNumber + options.lockTimeout; assert( options.secret === undefined || Secret.is(options.secret), @@ -914,61 +912,34 @@ export class Raiden { this.log.info, ); - const pathFindMeta = { tokenNetwork, target, value: decodedValue }; - return merge( - // wait for pathFind response - this.action$.pipe( - first(isResponseOf(pathFind, pathFindMeta)), + const promise = this.action$ + .pipe( + filter(isActionOf([transferSigned, transfer.failure])), + first(({ meta }) => meta.direction === Direction.SENT && meta.secrethash === secrethash), map((action) => { - if (pathFind.failure.is(action)) throw action.payload; - return action.payload.paths; + if (transfer.failure.is(action)) throw action.payload; + return transferKey(action.meta); }), - ), - // request pathFind; even if paths were provided, send it again for validation - // this is done at 'merge' subscription time (i.e. when above action filter is subscribed) - defer(() => { - this.store.dispatch(pathFind.request({ paths, pfs }, pathFindMeta)); - return EMPTY; - }), - ) - .pipe( - mergeMap((paths) => - merge( - // wait for transfer response - this.action$.pipe( - filter(isActionOf([transferSigned, transfer.failure])), - first( - (action) => - action.meta.direction === Direction.SENT && - action.meta.secrethash === secrethash, - ), - map((action) => { - if (transfer.failure.is(action)) throw action.payload; - return transferKey(action.meta); - }), - ), - // request transfer with returned/validated paths at 'merge' subscription time - defer(() => { - this.store.dispatch( - transfer.request( - { - tokenNetwork, - target, - value: decodedValue, - paymentId, - secret, - expiration, - ...metadataFromPaths(paths), - }, - { secrethash, direction: Direction.SENT }, - ), - ); - return EMPTY; - }), - ), - ), ) .toPromise(); + this.store.dispatch( + transfer.request( + { + tokenNetwork, + target, + value: decodedValue, + paymentId, + secret, + resolved: false, + paths, + pfs, + lockTimeout: options.lockTimeout, + encryptSecret: options.encryptSecret, + }, + { secrethash, direction: Direction.SENT }, + ), + ); + return promise; } /** diff --git a/raiden-ts/src/transfers/actions.ts b/raiden-ts/src/transfers/actions.ts index ff4269ef01..43cd94050a 100644 --- a/raiden-ts/src/transfers/actions.ts +++ b/raiden-ts/src/transfers/actions.ts @@ -13,6 +13,7 @@ import { WithdrawExpired, WithdrawRequest, } from '../messages/types'; +import { Paths, PFS } from '../services/types'; import { Via } from '../transport/types'; import type { ActionType } from '../utils/actions'; import { createAction, createAsyncAction } from '../utils/actions'; @@ -52,16 +53,25 @@ export const transfer = createAsyncAction( target: Address, value: UInt(32), paymentId: UInt(8), - metadata: t.unknown, - fee: Int(32), - partner: Address, - }), - Via, - t.partial({ - secret: Secret, - expiration: t.number, - initiator: Address, }), + t.union([ + t.intersection([ + t.type({ resolved: t.literal(false) }), + t.partial({ paths: Paths, pfs: t.union([PFS, t.null]), encryptSecret: t.boolean }), + ]), + t.intersection([ + t.type({ + resolved: t.literal(true), + metadata: t.unknown, + fee: Int(32), + partner: Address, + }), + Via, + ]), + ]), + + t.partial({ secret: Secret, initiator: Address }), + t.union([t.partial({ expiration: t.number }), t.partial({ lockTimeout: t.number })]), ]), t.partial({ balanceProof: Signed(BalanceProof) }), ); diff --git a/raiden-ts/src/transfers/epics/init.ts b/raiden-ts/src/transfers/epics/init.ts index f9fd62d128..9061119dde 100644 --- a/raiden-ts/src/transfers/epics/init.ts +++ b/raiden-ts/src/transfers/epics/init.ts @@ -1,7 +1,9 @@ -import pick from 'lodash/fp/pick'; +import omit from 'lodash/omit'; +import pick from 'lodash/pick'; import type { Observable } from 'rxjs'; import { EMPTY, from, identity, merge, of } from 'rxjs'; import { + catchError, debounceTime, filter, first, @@ -18,14 +20,21 @@ import { import type { RaidenAction } from '../../actions'; import { Capabilities } from '../../constants'; +import { pathFind } from '../../services/actions'; import type { RaidenState } from '../../state'; import { matrixPresence } from '../../transport/actions'; import { getCap } from '../../transport/utils'; import type { RaidenEpicDeps } from '../../types'; -import { completeWith, distinctRecordValues, pluckDistinct } from '../../utils/rx'; +import { + completeWith, + dispatchRequestAndGetResponse, + distinctRecordValues, + pluckDistinct, +} from '../../utils/rx'; import type { Hash } from '../../utils/types'; import { untime } from '../../utils/types'; import { + transfer, transferClear, transferExpire, transferSecret, @@ -35,7 +44,7 @@ import { transferUnlock, } from '../actions'; import { Direction } from '../state'; -import { transferKey } from '../utils'; +import { metadataFromPaths, transferKey } from '../utils'; /** * Re-queue pending transfer's BalanceProof/Envelope messages for retry on init @@ -168,6 +177,65 @@ export function initQueuePendingReceivedEpic( ); } +/** + * @param action$ - Observable of unresolved transfer.request actions + * @param state$ - Observable of RaidenStates + * @param deps - Epics dependenceis + * @param deps.config$ - Config observable + * @returns Observable of pathFind.request and resolved transfer.request actions + */ +export function transferRequestResolveEpic( + action$: Observable, + {}: Observable, + { config$ }: RaidenEpicDeps, +) { + return action$.pipe( + dispatchRequestAndGetResponse(pathFind, (dispatch) => + action$.pipe( + filter(transfer.request.is), + filter( + (action): action is transfer.request & { payload: { resolved: false } } => + !action.payload.resolved, + ), + mergeMap((action) => + dispatch( + pathFind.request( + pick(action.payload, ['paths', 'pfs'] as const), + pick(action.payload, ['tokenNetwork', 'target', 'value'] as const), + ), + ).pipe( + withLatestFrom( + action$.pipe( + filter(matrixPresence.success.is), + filter((a) => a.meta.address === action.payload.target), + ), + config$, + ), + map(([route, targetPresence, { encryptSecret }]) => { + const resolved = + (action.payload.encryptSecret ?? encryptSecret) && action.payload.secret + ? metadataFromPaths(route.payload.paths, targetPresence, { + secret: action.payload.secret, + amount: action.payload.value, + payment_identifier: action.payload.paymentId, + }) + : metadataFromPaths(route.payload.paths, targetPresence); + return transfer.request( + { + ...omit(action.payload, ['paths', 'pfs', 'encryptSecret'] as const), + ...resolved, + }, + action.meta, + ); + }), + catchError((err) => of(transfer.failure(err, action.meta))), + ), + ), + ), + ), + ); +} + function hasTransferMeta( // eslint-disable-next-line @typescript-eslint/no-explicit-any action: any, @@ -212,7 +280,7 @@ export function transferClearCompletedEpic( action$.pipe( filter(hasTransferMeta), filter((action) => transferKey(action.meta) === grouped$.key), - startWith({ meta: pick(['secrethash', 'direction'], transfer) }), + startWith({ meta: pick(transfer, ['secrethash', 'direction'] as const) }), ), ), completeWith(action$), diff --git a/raiden-ts/src/transfers/epics/locked.ts b/raiden-ts/src/transfers/epics/locked.ts index a4ab76b98f..1ab6888abe 100644 --- a/raiden-ts/src/transfers/epics/locked.ts +++ b/raiden-ts/src/transfers/epics/locked.ts @@ -102,6 +102,11 @@ function getOpenChannel( return channel; } +type transferRequestResolved = transfer.request & { payload: { resolved: true } }; +function transferRequestIsResolved(action: transfer.request): action is transferRequestResolved { + return action.payload.resolved; +} + /** * The core logic of {@link makeAndSignTransfer}. * @@ -120,7 +125,7 @@ function getOpenChannel( */ function makeAndSignTransfer$( state: RaidenState, - action: transfer.request, + action: transferRequestResolved, { revealTimeout, confirmationBlocks, expiryFactor }: RaidenConfig, { log, address, network, signer }: RaidenEpicDeps, ): Observable { @@ -128,16 +133,22 @@ function makeAndSignTransfer$( const channel = getOpenChannel(state, { tokenNetwork, partner }); assert( - !action.payload.expiration || action.payload.expiration > state.blockNumber + revealTimeout, + !('expiration' in action.payload) || + !action.payload.expiration || + action.payload.expiration > state.blockNumber + revealTimeout, 'expiration too soon', ); - const expiration = BigNumber.from( - action.payload.expiration || - Math.min( - state.blockNumber + Math.round(revealTimeout * expiryFactor), - state.blockNumber + channel.settleTimeout - confirmationBlocks, - ), - ) as UInt<32>; + const expiration = decode( + UInt(32), + 'expiration' in action.payload && action.payload.expiration + ? action.payload.expiration + : 'lockTimeout' in action.payload && action.payload.lockTimeout + ? state.blockNumber + action.payload.lockTimeout + : Math.min( + state.blockNumber + Math.round(revealTimeout * expiryFactor), + state.blockNumber + channel.settleTimeout - confirmationBlocks, + ), + ); assert(expiration.lte(state.blockNumber + channel.settleTimeout), [ 'expiration too far in the future', { @@ -218,7 +229,7 @@ function makeAndSignTransfer$( */ function sendTransferSigned( state$: Observable, - action: transfer.request, + action: transferRequestResolved, deps: RaidenEpicDeps, ): Observable { return combineLatest([state$, deps.config$]).pipe( diff --git a/raiden-ts/src/transfers/mediate/epics.ts b/raiden-ts/src/transfers/mediate/epics.ts index 7c67a39032..4f71933edd 100644 --- a/raiden-ts/src/transfers/mediate/epics.ts +++ b/raiden-ts/src/transfers/mediate/epics.ts @@ -8,13 +8,13 @@ import type { RaidenConfig } from '../../config'; import { Capabilities } from '../../constants'; import { Metadata } from '../../messages/types'; import type { RaidenState } from '../../state'; -import type { Via } from '../../transport/types'; import { getCap, parseCaps } from '../../transport/utils'; import type { RaidenEpicDeps } from '../../types'; import type { Address, Int } from '../../utils/types'; import { decode, isntNil } from '../../utils/types'; import { transfer, transferSigned } from '../actions'; import { Direction } from '../state'; +import type { metadataFromPaths } from '../utils'; import { clearMetadataRoute, searchValidMetadata } from '../utils'; function shouldMediate(action: transferSigned, address: Address, { caps }: RaidenConfig): boolean { @@ -44,8 +44,12 @@ function findValidPartner( received: transferSigned, state: RaidenState, config: RaidenConfig, - { address, log, mediationFeeCalculator }: RaidenEpicDeps, -): Pick | undefined { + { + address, + log, + mediationFeeCalculator, + }: Pick, +): ReturnType | undefined { const message = received.payload.message; const inPartner = received.payload.partner; const tokenNetwork = message.token_network_address; @@ -78,9 +82,6 @@ function findValidPartner( log.warn('Mediation: could not calculate mediation fee, ignoring', { error }); continue; } - // on a transfer.request, fee is *added* to the value to get final sent amount, - // therefore here it needs to contain a negative fee, which we will "earn" instead of pay - fee = fee.mul(-1) as Int<32>; const outPartnerMetadata = searchValidMetadata(address_metadata, outPartner); let metadata = message.metadata; // pass through metadata @@ -88,7 +89,15 @@ function findValidPartner( if (!getCap(parseCaps(outPartnerMetadata?.capabilities), Capabilities.IMMUTABLE_METADATA)) metadata = clearMetadataRoute(address, metadata); - return { partner: outPartner, fee, userId: outPartnerMetadata?.user_id, metadata }; + return { + resolved: true, + partner: outPartner, + // on a transfer.request, fee is *added* to the value to get final sent amount, + // therefore here it needs to contain a negative fee, which we will "earn" instead of pay + fee: fee.mul(-1) as Int<32>, + userId: outPartnerMetadata?.user_id, + metadata, + }; } log.warn('Mediation: could not find a suitable route, ignoring', { inputRoutes: decodedMetadata.routes, From d25b99099484d14f60903356bd7ce289ae715cc6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Vitor=20de=20Lima=20Matos?= Date: Wed, 7 Jul 2021 22:00:09 -0300 Subject: [PATCH 06/11] sdk: send encrypted secret if partner supports it and decode it if we can. decoding is always attempted in the receiving side. partner support is checked against `caps.immutableMetadata`, and we always attempt to send it to any target regardless of their capabilities. If they don't support it, they'll just ignore it. --- raiden-ts/src/transfers/epics/locked.ts | 30 ++++++--- raiden-ts/src/transfers/state.ts | 8 ++- raiden-ts/src/transfers/utils.ts | 83 ++++++++++++++++++++----- 3 files changed, 96 insertions(+), 25 deletions(-) diff --git a/raiden-ts/src/transfers/epics/locked.ts b/raiden-ts/src/transfers/epics/locked.ts index 1ab6888abe..233389c085 100644 --- a/raiden-ts/src/transfers/epics/locked.ts +++ b/raiden-ts/src/transfers/epics/locked.ts @@ -47,7 +47,7 @@ import { ErrorCodes, RaidenError } from '../../utils/error'; import { LruCache } from '../../utils/lru'; import { completeWith, pluckDistinct } from '../../utils/rx'; import type { Address, Hash, Int } from '../../utils/types'; -import { decode, Signed, UInt, untime } from '../../utils/types'; +import { decode, Secret, Signed, UInt, untime } from '../../utils/types'; import { transfer, transferExpire, @@ -66,6 +66,7 @@ import { import type { TransferState } from '../state'; import { Direction } from '../state'; import { + decryptSecretFromMetadata, getLocksroot, getSecrethash, getTransfer, @@ -457,6 +458,7 @@ function receiveTransferSigned( | transferProcessed | transferSecretRequest | matrixPresence.request + | transferSecret > { const secrethash = action.payload.message.lock.secrethash; const meta = { secrethash, direction: Direction.RECEIVED }; @@ -538,7 +540,7 @@ function receiveTransferSigned( partner, ); - let request$: Observable | undefined> = of(undefined); + let request$: Observable | undefined> = of(undefined); if (locked.target === address) { let ignoredDetails; if (!getCap(caps, Capabilities.RECEIVE)) ignoredDetails = { reason: 'receiving disabled' }; @@ -548,8 +550,16 @@ function receiveTransferSigned( lockExpiration: locked.lock.expiration.toString(), dangerZoneStart: locked.lock.expiration.sub(revealTimeout).toString(), }; - if (!ignoredDetails) { + if (ignoredDetails) { + log.warn('Ignoring received transfer', ignoredDetails); + } else { request$ = defer(async () => { + const decryptedSecret = decryptSecretFromMetadata( + locked.metadata, + [secrethash, locked.lock.amount, locked.payment_identifier], + signer, + ); + if (decryptedSecret) return decryptedSecret; const request: SecretRequest = { type: MessageType.SECRET_REQUEST, payment_identifier: locked.payment_identifier, @@ -560,8 +570,6 @@ function receiveTransferSigned( }; return signMessage(signer, request, { log }); }); - } else { - log.warn('Ignoring received transfer', ignoredDetails); } } @@ -575,17 +583,19 @@ function receiveTransferSigned( // if any of these signature prompts fail, none of these actions will be emitted return combineLatest([processed$, request$]).pipe( - mergeMap(function* ([processed, request]) { + mergeMap(function* ([processed, requestOrSecret]) { yield transferSigned({ message: locked, fee: Zero as Int<32>, partner }, meta); // sets TransferState.transferProcessed yield transferProcessed({ message: processed, userId: action.payload.userId }, meta); - if (request) { + if (Secret.is(requestOrSecret)) { + yield transferSecret({ secret: requestOrSecret }, meta); + } else if (requestOrSecret) { // request initiator's presence, to be able to request secret yield matrixPresence.request(undefined, { address: locked.initiator }); // request secret iff we're the target and receiving is enabled yield transferSecretRequest( { - message: request, + message: requestOrSecret, ...searchValidViaAddress(locked.metadata, locked.initiator), }, meta, @@ -1195,7 +1205,9 @@ export function transferGenerateAndSignEnvelopeMessageEpic( let output$; switch (action.type) { case transfer.request.type: - output$ = sendTransferSigned(state$, action, deps); + if (transferRequestIsResolved(action)) + output$ = sendTransferSigned(state$, action, deps); + else output$ = EMPTY; break; case transferUnlock.request.type: output$ = sendTransferUnlocked(state$, action, deps); diff --git a/raiden-ts/src/transfers/state.ts b/raiden-ts/src/transfers/state.ts index 72ef7d3fd4..e2c01acd15 100644 --- a/raiden-ts/src/transfers/state.ts +++ b/raiden-ts/src/transfers/state.ts @@ -10,7 +10,7 @@ import { SecretReveal, Unlock, } from '../messages/types'; -import { Address, Hash, Int, Secret, Signed, Timed } from '../utils/types'; +import { Address, Hash, Int, Secret, Signed, Timed, UInt } from '../utils/types'; // it's like an enum, but with literals export const Direction = { @@ -148,3 +148,9 @@ export interface RaidenTransfer { completed: boolean; secret?: Secret; } + +export const RevealedSecret = t.intersection([ + t.type({ secret: Secret, amount: UInt(32) }), + t.partial({ payment_identifier: UInt(8) }), +]); +export type RevealedSecret = t.TypeOf; diff --git a/raiden-ts/src/transfers/utils.ts b/raiden-ts/src/transfers/utils.ts index a5c853c451..0597ed0cad 100644 --- a/raiden-ts/src/transfers/utils.ts +++ b/raiden-ts/src/transfers/utils.ts @@ -1,9 +1,12 @@ +import type { Signer } from '@ethersproject/abstract-signer'; import { BigNumber } from '@ethersproject/bignumber'; -import { concat as concatBytes, hexlify } from '@ethersproject/bytes'; +import { arrayify, concat as concatBytes, hexlify } from '@ethersproject/bytes'; import { HashZero } from '@ethersproject/constants'; import { keccak256 } from '@ethersproject/keccak256'; import { randomBytes } from '@ethersproject/random'; import { sha256 } from '@ethersproject/sha2'; +import type { Wallet } from '@ethersproject/wallet'; +import { decrypt, encrypt } from 'eciesjs'; import * as t from 'io-ts'; import isEmpty from 'lodash/isEmpty'; import type { Observable } from 'rxjs'; @@ -25,15 +28,15 @@ import { } from '../messages/utils'; import type { Paths } from '../services/types'; import type { RaidenState } from '../state'; -import type { Via } from '../transport/types'; +import type { matrixPresence } from '../transport/actions'; +import type { Caps, Via } from '../transport/types'; import { getCap, parseCaps } from '../transport/utils'; import { assert } from '../utils'; -import { encode } from '../utils/data'; -import type { Address, Hash, HexString, Secret, UInt } from '../utils/types'; -import { decode, isntNil } from '../utils/types'; -import type { transfer } from './actions'; +import { encode, jsonParse, jsonStringify } from '../utils/data'; +import type { Address, Hash, Int, PrivateKey, Secret, UInt } from '../utils/types'; +import { decode, HexString, isntNil } from '../utils/types'; import type { RaidenTransfer } from './state'; -import { Direction, RaidenTransferStatus, TransferState } from './state'; +import { Direction, RaidenTransferStatus, RevealedSecret, TransferState } from './state'; /** * Get the locksroot of a given array of pending locks @@ -301,29 +304,79 @@ export function clearMetadataRoute( * Contructs transfer.request's payload paramaters from received PFS's Paths * * @param paths - Paths array coming from PFS - * @returns Respective members of transfer.request's payload, with stricter 'metadata' + * @param target - presence of target address + * @param encryptSecret - Try to encrypt this secret object to target + * @returns Respective members of transfer.request's payload */ export function metadataFromPaths( paths: Paths, -): Pick & { metadata: Metadata } { + target: matrixPresence.success, + encryptSecret?: RevealedSecret, +): Readonly<{ resolved: true; fee: Int<32>; partner: Address; metadata: unknown } & Via> { // paths may come with undesired parameters, so map&filter here before passing to metadata const routes = paths.map(({ path: route, fee: _, address_metadata }) => ({ route, ...(address_metadata && !isEmpty(address_metadata) ? { address_metadata } : {}), })); const viaPath = paths[0]; - const partner = viaPath.path[1]; // we're first address in route, partner is 2nd const fee = viaPath.fee; - const partnerMetadata = searchValidMetadata(viaPath.address_metadata, partner); - const via: Via = { userId: partnerMetadata?.user_id }; + const partner = viaPath.path[1]; // we're first address in route, partner is 2nd + let partnerUserId: string | undefined, partnerCaps: Caps | null | undefined; + if (partner === target.meta.address) { + partnerUserId = target.payload.userId; + partnerCaps = target.payload.caps; + } else { + const partnerMetadata = searchValidMetadata(viaPath.address_metadata, partner); + partnerUserId = partnerMetadata?.user_id; + partnerCaps = parseCaps(partnerMetadata?.capabilities); + } + const via: Via = { userId: partnerUserId }; - let metadata: Metadata = { routes }; + let metadata: Metadata & { secret?: HexString } = { routes }; // iff partner requires a clear route (to be first address), clear it; // in routes received from PFS, we're always first address and partner second - if (!getCap(parseCaps(partnerMetadata?.capabilities), Capabilities.IMMUTABLE_METADATA)) + if (!getCap(partnerCaps, Capabilities.IMMUTABLE_METADATA)) metadata = clearMetadataRoute(viaPath.path[0], metadata); + else if (encryptSecret) { + const encrypted = hexlify( + encrypt( + target.payload.pubkey, + Buffer.from(jsonStringify(RevealedSecret.encode(encryptSecret))), + ), + ) as HexString; + metadata = { ...metadata, secret: encrypted }; + } + + return { resolved: true, metadata, fee, partner, ...via }; +} - return { metadata, fee, partner, ...via }; +const EncryptedSecretMetadata = t.type({ secret: HexString() }); +type EncryptedSecretMetadata = t.TypeOf; + +/** + * @param metadata - Undecoded metadata + * @param transfer - Transfer info + * @param transfer."0" - Transfer's secrethash + * @param transfer."1" - Transfer's effective received amount + * @param transfer."2" - Transfer's paymendId + * @param signer - Our effective signer (with `privateKey`) + * @returns Secret, if decryption and all validations pass + */ +export function decryptSecretFromMetadata( + metadata: unknown, + [secrethash, amount, paymentId]: readonly [Hash, UInt<32>, UInt<8>?], + signer: Signer, +): Secret | undefined { + const privkey = (signer as Wallet).privateKey as PrivateKey; + if (!privkey) return; + try { + const encrypted = decode(EncryptedSecretMetadata, metadata).secret; + const decrypted = decrypt(privkey, Buffer.from(arrayify(encrypted))).toString(); + const parsed = decode(RevealedSecret, jsonParse(decrypted)); + assert(amount.gte(parsed.amount) && getSecrethash(parsed.secret) === secrethash); + assert(!paymentId || !parsed.payment_identifier || paymentId.eq(parsed.payment_identifier)); + return parsed.secret; + } catch (e) {} } /** From 650889b2d39d3880716a2fdfeb6c9b486acfd48a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Vitor=20de=20Lima=20Matos?= Date: Wed, 7 Jul 2021 22:01:02 -0300 Subject: [PATCH 07/11] sdk: no need to open RTC channel if secret is present in transfer Initiator never tries to call over RTC, and instead just whitelist target's. target then is to decide whether to call initiator, and does it only if 'secret' is not in transfer.metadata, meaning we'll need to ask them for it. --- raiden-ts/src/transport/epics/webrtc.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/raiden-ts/src/transport/epics/webrtc.ts b/raiden-ts/src/transport/epics/webrtc.ts index f0379fa8a0..2015a3c742 100644 --- a/raiden-ts/src/transport/epics/webrtc.ts +++ b/raiden-ts/src/transport/epics/webrtc.ts @@ -611,11 +611,10 @@ function getAddressOfInterest(action: RaidenAction, { address }: Pick)) ) peer = action.payload.message.initiator; } else if (messageSend.request.is(action) && action.payload.msgtype !== rtcMatrixMsgType) { From 30704981e9fbe3b382653d7126363685ad766535 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Vitor=20de=20Lima=20Matos?= Date: Sat, 10 Jul 2021 17:06:12 -0300 Subject: [PATCH 08/11] sdk: improve some statements in transfer and 'Last' type Just minor typing and coding usage, no logic change. --- raiden-ts/src/messages/utils.ts | 2 +- raiden-ts/src/raiden.ts | 15 ++++----------- raiden-ts/src/services/utils.ts | 5 +++-- raiden-ts/src/transport/epics/presence.ts | 2 +- raiden-ts/src/utils/types.ts | 8 +++++--- 5 files changed, 14 insertions(+), 18 deletions(-) diff --git a/raiden-ts/src/messages/utils.ts b/raiden-ts/src/messages/utils.ts index 592f428a9b..3d39b2308c 100644 --- a/raiden-ts/src/messages/utils.ts +++ b/raiden-ts/src/messages/utils.ts @@ -434,7 +434,7 @@ export function isMessageReceivedOfType(messageCodecs: C | C[ export function validateAddressMetadata( metadata: AddressMetadata | undefined, address: Address, - { log }: { log: logging.Logger } = { log: logging }, + { log }: Partial> = {}, ): [metadata: AddressMetadata, pubkey: PublicKey] | undefined { if (!metadata) return; try { diff --git a/raiden-ts/src/raiden.ts b/raiden-ts/src/raiden.ts index 52fd1bc033..98aa7db643 100644 --- a/raiden-ts/src/raiden.ts +++ b/raiden-ts/src/raiden.ts @@ -881,12 +881,9 @@ export class Raiden { options.paymentId !== undefined ? decode(UInt(8), options.paymentId, ErrorCodes.DTA_INVALID_PAYMENT_ID, this.log.info) : makePaymentId(); - const paths = !options.paths - ? undefined - : decode(Paths, options.paths, ErrorCodes.DTA_INVALID_PATH, this.log.info); - const pfs = !options.pfs - ? undefined - : decode(PFS, options.pfs, ErrorCodes.DTA_INVALID_PFS, this.log.info); + const paths = + options.paths && decode(Paths, options.paths, ErrorCodes.DTA_INVALID_PATH, this.log.info); + const pfs = options.pfs && decode(PFS, options.pfs, ErrorCodes.DTA_INVALID_PFS, this.log.info); assert( options.secret === undefined || Secret.is(options.secret), @@ -900,11 +897,7 @@ export class Raiden { ); // use provided secret or create one if no secrethash was provided - const secret = options.secret - ? options.secret - : !options.secrethash - ? makeSecret() - : undefined; + const secret = options.secret || (options.secrethash ? undefined : makeSecret()); const secrethash = options.secrethash || getSecrethash(secret!); assert( !secret || getSecrethash(secret) === secrethash, diff --git a/raiden-ts/src/services/utils.ts b/raiden-ts/src/services/utils.ts index 588ad6d44e..27bafce09d 100644 --- a/raiden-ts/src/services/utils.ts +++ b/raiden-ts/src/services/utils.ts @@ -222,15 +222,16 @@ export function pfsListInfo( export function getPresenceFromService$( peer: Address, pfsAddrOrUrl: string, - { serviceRegistryContract }: Pick, + deps: Pick, ): Observable<{ user_id: string; capabilities: Caps; pubkey: PublicKey }> { + const { serviceRegistryContract } = deps; return defer(async () => pfsAddressUrl(pfsAddrOrUrl, { serviceRegistryContract })).pipe( mergeMap((url) => fromFetch(`${url}/api/v1/address/${peer}/metadata`)), mergeMap(async (res) => res.json()), map((json) => { try { const presence = decode(AddressMetadata, json); - const meta = validateAddressMetadata(presence, peer); + const meta = validateAddressMetadata(presence, peer, deps); assert(meta, ['Invalid metadata signature', { peer, presence }]); const [, pubkey] = meta; const capabilities = parseCaps(presence.capabilities); diff --git a/raiden-ts/src/transport/epics/presence.ts b/raiden-ts/src/transport/epics/presence.ts index 87006d8327..edca074f8c 100644 --- a/raiden-ts/src/transport/epics/presence.ts +++ b/raiden-ts/src/transport/epics/presence.ts @@ -46,7 +46,7 @@ import { stringifyCaps } from '../utils'; */ function searchAddressPresence$( address: Address, - deps: Pick, + deps: Pick, ) { const { config$, latest$ } = deps; return combineLatest([latest$, config$]).pipe( diff --git a/raiden-ts/src/utils/types.ts b/raiden-ts/src/utils/types.ts index 72b0420049..e954ae9d13 100644 --- a/raiden-ts/src/utils/types.ts +++ b/raiden-ts/src/utils/types.ts @@ -292,7 +292,9 @@ export const instanceOf: (name: string) => t.Type = memoize( * Infer type of last element of a tuple or array * Currently supports tuples of up to 9 elements before falling back to array's inference */ -export type Last = T extends [...any[], infer L] ? L : T[number] | undefined; +export type Last = T extends readonly [...unknown[], infer L] + ? L + : T[number] | undefined; /** * Like lodash's last, but properly infer return type when argument is a tuple @@ -300,8 +302,8 @@ export type Last = T extends [...any[], infer L] ? L : T[number * @param arr - Tuple or array to get last element from * @returns Last element from arr */ -export function last(arr: T): Last { - return arr[arr.length - 1]; +export function last(arr: T): Last { + return arr[arr.length - 1] as Last; } /** From 2e5483855463d74b2404ea02d4c5c7e77e659dfa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Vitor=20de=20Lima=20Matos?= Date: Sat, 10 Jul 2021 18:38:35 -0300 Subject: [PATCH 09/11] sdk: fix and add tests for transfer resolving and pubkey in presence --- raiden-ts/CHANGELOG.md | 2 + raiden-ts/tests/integration/fixtures.ts | 75 +++++++---- raiden-ts/tests/integration/mediate.spec.ts | 11 +- raiden-ts/tests/integration/mocks.ts | 5 +- raiden-ts/tests/integration/patches.ts | 125 +++++++++--------- raiden-ts/tests/integration/path.spec.ts | 23 +--- raiden-ts/tests/integration/send.spec.ts | 77 +++++++++++ raiden-ts/tests/integration/transport.spec.ts | 45 +++---- raiden-ts/tests/unit/messages.spec.ts | 3 + raiden-ts/tests/unit/raiden.spec.ts | 77 ++++------- raiden-ts/tests/utils.ts | 12 +- 11 files changed, 271 insertions(+), 184 deletions(-) diff --git a/raiden-ts/CHANGELOG.md b/raiden-ts/CHANGELOG.md index 18bdbe460b..b540837b00 100644 --- a/raiden-ts/CHANGELOG.md +++ b/raiden-ts/CHANGELOG.md @@ -3,10 +3,12 @@ ## [Unreleased] ### Added - [#2766] Add `Capabilities.IMMUTABLE_METADATA` (true on LC, fallback to falsy for backwards compatibility) to allow opting in of not prunning metadata.route and allowing to pass it through mediators unchanged +- [#2730] Add `config.encryptSecret` and `Raiden.transfer`'s `encryptSecret` boolean option, to allow sending secret to target on LockedTransfer's metadata, encrypted with ECIES over their publicKey, skipping SecretRequest/Reveal and speeding up transfers. ### Fixed - [#2831] Force PFS to acknowledge our capabilities updates +[#2730]: https://github.com/raiden-network/light-client/issues/2730 [#2766]: https://github.com/raiden-network/light-client/pull/2766 [#2831]: https://github.com/raiden-network/light-client/issues/2831 diff --git a/raiden-ts/tests/integration/fixtures.ts b/raiden-ts/tests/integration/fixtures.ts index c0dc22116c..85053d6b66 100644 --- a/raiden-ts/tests/integration/fixtures.ts +++ b/raiden-ts/tests/integration/fixtures.ts @@ -22,9 +22,9 @@ import { } from '@/transfers/utils'; import { matrixPresence } from '@/transport/actions'; import { stringifyCaps } from '@/transport/utils'; -import type { Latest } from '@/types'; import { assert } from '@/utils'; -import type { Address, Hash, Int, Secret, UInt } from '@/utils/types'; +import type { Address, Hash, Int, PublicKey, Secret, UInt } from '@/utils/types'; +import { last } from '@/utils/types'; import { makeAddress, makeHash, sleep } from '../utils'; import type { MockedRaiden } from './mocks'; @@ -290,6 +290,7 @@ export async function ensureTransferPending( target: partner.address, value, paymentId, + resolved: true, metadata: { routes: [{ route: [partner.address] }] }, fee: Zero as Int<32>, partner: partner.address, @@ -378,6 +379,7 @@ export async function ensurePresence([raiden, partner]: [ available: true, ts: Date.now() + 120e3, caps: (await raiden.deps.latest$.pipe(first()).toPromise()).config.caps!, + pubkey: raiden.deps.signer.publicKey as PublicKey, }, { address: raiden.address }, ), @@ -389,6 +391,7 @@ export async function ensurePresence([raiden, partner]: [ available: true, ts: Date.now() + 120e3, caps: (await partner.deps.latest$.pipe(first()).toPromise()).config.caps!, + pubkey: partner.deps.signer.publicKey as PublicKey, }, { address: partner.address }, ), @@ -408,6 +411,24 @@ export function expectChannelsAreInSync([raiden, partner]: [MockedRaiden, Mocked expect(getChannel(raiden, partner).partner).toStrictEqual(getChannel(partner, raiden).own); } +/** + * @param client - mocked client + * @param available - override client.started on returned availability + * @returns client's presence + */ +export function presenceFromClient(client: MockedRaiden, available = !!client.started) { + return matrixPresence.success( + { + userId: client.store.getState().transport.setup!.userId, + available, + ts: Date.now(), + pubkey: client.deps.signer.publicKey as PublicKey, + caps: client.config.caps!, + }, + { address: client.address }, + ); +} + /** * @param clients - Clients list * @param clients."0" - Main/our raiden instance @@ -415,30 +436,32 @@ export function expectChannelsAreInSync([raiden, partner]: [MockedRaiden, Mocked * @param fee_ - Estimated transfer fee * @returns metadataFromPaths for a tansfer.request's payload */ -export function metadataFromClients( - clients: readonly [T, ...T[]], +export function metadataFromClients( + clients: readonly [...(Address | MockedRaiden)[], MockedRaiden], fee_ = fee, ) { - const isRaiden = (c: T): c is T & MockedRaiden => typeof c !== 'string'; - return metadataFromPaths([ - { - path: clients.map((c) => (isRaiden(c) ? c.address : (c as Address))), - fee: fee_, - address_metadata: Object.fromEntries( - clients.filter(isRaiden).map(({ address, store, deps }) => { - const setup = store.getState().transport.setup!; - let latest!: Latest; - deps.latest$.pipe(first()).subscribe((l) => (latest = l)); - return [ - address, - { - user_id: setup.userId, - displayname: setup.displayName, - capabilities: stringifyCaps(latest.config.caps!), - }, - ] as const; - }), - ), - }, - ]); + const isRaiden = (c: Address | MockedRaiden): c is MockedRaiden => typeof c !== 'string'; + const targetPresence = presenceFromClient(last(clients)); + return metadataFromPaths( + [ + { + path: clients.map((c) => (isRaiden(c) ? c.address : (c as Address))), + fee: fee_, + address_metadata: Object.fromEntries( + clients.filter(isRaiden).map(({ address, store, config }) => { + const setup = store.getState().transport.setup!; + return [ + address, + { + user_id: setup.userId, + displayname: setup.displayName, + capabilities: stringifyCaps(config.caps!), + }, + ] as const; + }), + ), + }, + ], + targetPresence, + ); } diff --git a/raiden-ts/tests/integration/mediate.spec.ts b/raiden-ts/tests/integration/mediate.spec.ts index c02b2e1998..3d5328b6e9 100644 --- a/raiden-ts/tests/integration/mediate.spec.ts +++ b/raiden-ts/tests/integration/mediate.spec.ts @@ -24,7 +24,7 @@ import { Direction } from '@/transfers/state'; import { makePaymentId } from '@/transfers/utils'; import type { Int, UInt } from '@/utils/types'; -import { makeAddress, sleep } from '../utils'; +import { sleep } from '../utils'; describe('mediate transfers', () => { test('success with flat fees', async () => { @@ -91,6 +91,7 @@ describe('mediate transfers', () => { paymentId: transf.payment_identifier, expiration: transf.lock.expiration.toNumber(), initiator: raiden.address, + resolved: true, fee: flat.mul(-1) as Int<32>, metadata: { routes: [ @@ -177,6 +178,7 @@ describe('mediate transfers', () => { paymentId: transf.payment_identifier, expiration: transf.lock.expiration.toNumber(), initiator: raiden.address, + resolved: true, fee: flat.mul(-1) as Int<32>, metadata: { routes: [ @@ -252,8 +254,7 @@ describe('mediate transfers', () => { test('skip if no suitable route', async () => { expect.assertions(3); - const [raiden, partner, target] = await makeRaidens(3); - const unknownTarget = makeAddress(); + const [raiden, partner, target, unknownTarget] = await makeRaidens(4); await ensureChannelIsDeposited([raiden, partner]); await ensureChannelIsOpen([partner, target], { channelId: 18 }); @@ -264,7 +265,7 @@ describe('mediate transfers', () => { transfer.request( { tokenNetwork, - target: unknownTarget, + target: unknownTarget.address, value: amount, paymentId: makePaymentId(), secret, @@ -284,7 +285,7 @@ describe('mediate transfers', () => { message: expect.objectContaining({ type: MessageType.LOCKED_TRANSFER, initiator: raiden.address, - target: unknownTarget, + target: unknownTarget.address, }), fee: Zero as Int<32>, partner: raiden.address, diff --git a/raiden-ts/tests/integration/mocks.ts b/raiden-ts/tests/integration/mocks.ts index d15a77688a..42e271d28f 100644 --- a/raiden-ts/tests/integration/mocks.ts +++ b/raiden-ts/tests/integration/mocks.ts @@ -368,7 +368,10 @@ function mockedMatrixCreateClient({ device_id?: string; }) => { address = getAddress(username); - assert(verifyMessage(server, password) === address, 'wrong password'); + assert(verifyMessage(server, password) === address, [ + 'wrong password', + { recovered: verifyMessage(server, password), server, password, address }, + ]); userId = `@${username}:${server}`; mockedMatrixUsers[userId] = { userId, diff --git a/raiden-ts/tests/integration/patches.ts b/raiden-ts/tests/integration/patches.ts index a1bb07e9ee..193df67e9b 100644 --- a/raiden-ts/tests/integration/patches.ts +++ b/raiden-ts/tests/integration/patches.ts @@ -1,74 +1,81 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { getAddress } from '@ethersproject/address'; -import type { BytesLike, Signature } from '@ethersproject/bytes'; +import type { BytesLike, Signature, SignatureLike } from '@ethersproject/bytes'; import { HashZero } from '@ethersproject/constants'; import type { Network } from '@ethersproject/networks'; import type { JsonRpcProvider } from '@ethersproject/providers'; -import { computeAddress } from '@ethersproject/transactions'; // ethers utils mock to skip slow elliptic sign/verify -const patchVerifyMessage = () => { - jest.mock('@ethersproject/signing-key', () => { - const origSigning = jest.requireActual('@ethersproject/signing-key'); - const { SigningKey } = origSigning; - class MockedSigningKey extends SigningKey { - private _address?: string; - signDigest({}: BytesLike): Signature { - if (!this._address) this._address = computeAddress(this.publicKey); - const s = HashZero.substr(0, 24) + this._address.substr(2).toLowerCase() + '00'; - return { - r: HashZero, - s, - _vs: s, - recoveryParam: 0, - v: 27, - }; - } - } - return { - ...origSigning, - __esModule: true, - SigningKey: MockedSigningKey, - }; - }); - jest.mock('@ethersproject/wallet', () => { - return { - ...jest.requireActual('@ethersproject/wallet'), - __esModule: true, - verifyMessage: jest.fn((_: string, sig: string): string => - getAddress('0x' + sig.substr(-44, 40)), - ), - }; - }); -}; +let mockOrigComputeAddress: (key: BytesLike | string) => string; +jest.mock('@ethersproject/transactions', () => { + const actual = jest.requireActual('@ethersproject/transactions'); + mockOrigComputeAddress = actual.computeAddress; + return { + ...actual, + __esModule: true, + computeAddress: jest.fn((sig: string): string => { + if (sig.startsWith('0x00000000')) return getAddress('0x' + sig.substr(-44, 40)); + return mockOrigComputeAddress(sig); + }), + }; +}); + +jest.mock('@ethersproject/wallet', () => { + return { + ...jest.requireActual('@ethersproject/wallet'), + __esModule: true, + verifyMessage: jest.fn((_: string, sig: string): string => + getAddress('0x' + sig.substr(-44, 40)), + ), + }; +}); + +jest.mock('@ethersproject/signing-key', () => { + const origSigning = jest.requireActual('@ethersproject/signing-key'); + const { SigningKey } = origSigning; + class MockedSigningKey extends SigningKey { + private _address?: string; + signDigest({}: BytesLike): Signature { + if (!this._address) this._address = mockOrigComputeAddress(this.publicKey); + const s = HashZero.substr(0, 24) + this._address!.substr(2).toLowerCase() + '00'; + return { + r: HashZero, + s, + _vs: s, + recoveryParam: 0, + v: 27, + }; + } + } + return { + ...origSigning, + __esModule: true, + SigningKey: MockedSigningKey, + recoverPublicKey: (_: BytesLike, signature: SignatureLike) => signature, + }; +}); // raiden-ts/utils.getNetwork has the same functionality as provider.getNetwork // but fetches everytime instead of just returning a cached property // On mocked tests, we unify both again, so we can just mock provider.getNetwork in-place -const patchEthersGetNetwork = () => - jest.mock('@/utils/ethers', () => ({ - ...jest.requireActual('@/utils/ethers'), - __esModule: true, - getNetwork: jest.fn((provider: JsonRpcProvider): Promise => provider.getNetwork()), - })); +jest.mock('@/utils/ethers', () => ({ + ...jest.requireActual('@/utils/ethers'), + __esModule: true, + getNetwork: jest.fn((provider: JsonRpcProvider): Promise => provider.getNetwork()), +})); // ethers's contracts use a lot defineReadOnly which doesn't allow us to mock // functions and properties. Mock it here so we can mock later -const patchEthersDefineReadOnly = () => - jest.mock('@ethersproject/properties', () => ({ - ...jest.requireActual('@ethersproject/properties'), - __esModule: true, - defineReadOnly: jest.fn((object: any, name: string, value: any): void => - Object.defineProperty(object, name, { - enumerable: true, - value, - writable: true, - configurable: true, - }), - ), - })); - -patchVerifyMessage(); -patchEthersDefineReadOnly(); -patchEthersGetNetwork(); +jest.mock('@ethersproject/properties', () => ({ + ...jest.requireActual('@ethersproject/properties'), + __esModule: true, + defineReadOnly: jest.fn((object: any, name: string, value: any): void => + Object.defineProperty(object, name, { + enumerable: true, + value, + writable: true, + configurable: true, + }), + ), +})); diff --git a/raiden-ts/tests/integration/path.spec.ts b/raiden-ts/tests/integration/path.spec.ts index f2a934cc56..ae3d713abc 100644 --- a/raiden-ts/tests/integration/path.spec.ts +++ b/raiden-ts/tests/integration/path.spec.ts @@ -8,6 +8,7 @@ import { fee, getChannel, openBlock, + presenceFromClient, token, tokenNetwork, } from './fixtures'; @@ -206,16 +207,7 @@ describe('PFS: pfsRequestEpic', () => { test('fail target not available', async () => { expect.assertions(1); - raiden.store.dispatch( - matrixPresence.success( - { - userId: target.store.getState().transport.setup!.userId, - available: false, - ts: Date.now(), - }, - { address: target.address }, - ), - ); + raiden.store.dispatch(presenceFromClient(target, false)); await waitBlock(); const pathFindMeta = { @@ -269,16 +261,7 @@ describe('PFS: pfsRequestEpic', () => { target: target.address, value: amount, }; - raiden.store.dispatch( - matrixPresence.success( - { - userId: `@${target.address.toLowerCase()}:matrix.raiden.test`, - available: false, - ts: Date.now(), - }, - { address: target.address }, - ), - ); + raiden.store.dispatch(presenceFromClient(target, false)); raiden.store.dispatch(pathFind.request({}, pathFindMeta)); await waitBlock(); diff --git a/raiden-ts/tests/integration/send.spec.ts b/raiden-ts/tests/integration/send.spec.ts index 0c2cc6f94b..862e9bb9e7 100644 --- a/raiden-ts/tests/integration/send.spec.ts +++ b/raiden-ts/tests/integration/send.spec.ts @@ -8,6 +8,7 @@ import { getChannel, getOrWaitTransfer, metadataFromClients, + presenceFromClient, secret, secrethash, tokenNetwork, @@ -27,6 +28,7 @@ import { messageReceived, messageSend } from '@/messages/actions'; import type { Processed } from '@/messages/types'; import { LockedTransfer, LockExpired, MessageType, Unlock } from '@/messages/types'; import { signMessage } from '@/messages/utils'; +import { pathFind } from '@/services/actions'; import { transfer, transferExpire, @@ -48,6 +50,81 @@ const paymentId = makePaymentId(); const value = amount; const meta = { secrethash, direction }; +describe('resolve transfer', () => { + test('success with encryptSecret', async () => { + const [raiden, partner] = await makeRaidens(2); + await ensureChannelIsDeposited([raiden, partner]); + + raiden.store.dispatch( + transfer.request( + { + tokenNetwork, + target: partner.address, + value, + paymentId, + secret, + resolved: false, + }, + meta, + ), + ); + await sleep(); + expect(raiden.output).toContainEqual( + transfer.request( + { + tokenNetwork, + target: partner.address, + value, + paymentId, + secret, + resolved: true, + metadata: { + routes: [ + expect.objectContaining({ + route: [raiden.address, partner.address], + }), + ], + secret: expect.any(String), + }, + partner: partner.address, + userId: partner.store.getState().transport.setup!.userId, + fee: Zero as Int<32>, + }, + meta, + ), + ); + }); + + test('failure target presence offline passes through', async () => { + const [raiden, partner] = await makeRaidens(2); + await ensureChannelIsDeposited([raiden, partner]); + raiden.store.dispatch(presenceFromClient(partner, false)); + + raiden.store.dispatch( + transfer.request( + { + tokenNetwork, + target: partner.address, + value, + paymentId, + secret, + resolved: false, + paths: [{ path: [raiden.address, partner.address], fee: Zero as Int<32> }], + }, + meta, + ), + ); + await sleep(); + expect(raiden.output).toContainEqual(pathFind.failure(expect.any(Error), expect.anything())); + expect(raiden.output).toContainEqual( + transfer.failure( + expect.objectContaining({ message: expect.stringContaining('offline') }), + meta, + ), + ); + }); +}); + describe('send transfer', () => { test('transferSigned success and cached', async () => { expect.assertions(6); diff --git a/raiden-ts/tests/integration/transport.spec.ts b/raiden-ts/tests/integration/transport.spec.ts index cc96ea06c8..71f50a14e1 100644 --- a/raiden-ts/tests/integration/transport.spec.ts +++ b/raiden-ts/tests/integration/transport.spec.ts @@ -1,7 +1,8 @@ import { ensureChannelIsOpen, ensurePresence, matrixServer } from './fixtures'; import { fetch, makeRaiden, makeRaidens, makeSignature } from './mocks'; -import { verifyMessage } from '@ethersproject/wallet'; +import { hexlify } from '@ethersproject/bytes'; +import { randomBytes } from '@ethersproject/random'; import { EventEmitter } from 'events'; import type { MatrixClient } from 'matrix-js-sdk'; import { first, pluck } from 'rxjs/operators'; @@ -20,12 +21,20 @@ import { getSortedAddresses } from '@/transport/utils'; import { jsonStringify } from '@/utils/data'; import { ErrorCodes } from '@/utils/error'; import { getServerName } from '@/utils/matrix'; -import type { Address, Signed } from '@/utils/types'; +import type { Address, PublicKey, Signed } from '@/utils/types'; import { isntNil } from '@/utils/types'; import { makeAddress, sleep } from '../utils'; import type { MockedRaiden } from './mocks'; +const mockedRecoverPublicKey = jest.fn( + jest.requireActual('@ethersproject/signing-key').recoverPublicKey, +); +jest.mock('@ethersproject/signing-key', () => ({ + ...jest.requireActual('@ethersproject/signing-key'), + recoverPublicKey: mockedRecoverPublicKey, +})); + const accessToken = 'access_token'; const deviceId = 'device_id'; const processed: Processed = { @@ -314,34 +323,17 @@ describe('matrixMonitorPresenceEpic', () => { ); }); - test('fails when users does not have valid addresses', async () => { - expect.assertions(1); - const [raiden, partner] = await makeRaidens(2); - json.mockImplementationOnce(async () => ({ - user_id: `@invalidUser:${matrixServer}`, - displayname: '0x1234', - capabilities, - })); - - raiden.store.dispatch(matrixPresence.request(undefined, { address: partner.address })); - - await sleep(2 * raiden.config.pollingInterval); - expect(raiden.output).toContainEqual( - matrixPresence.failure(expect.any(Error), { address: partner.address }), - ); - }); - - test('fails when verifyMessage throws', async () => { + test('fails when validation throws', async () => { expect.assertions(1); const [raiden, partner] = await makeRaidens(2); const partnerUserId = (await partner.deps.matrix$.toPromise()).getUserId()!; json.mockImplementationOnce(async () => ({ user_id: partnerUserId, - displayname: '0x1234', + displayname: hexlify(randomBytes(65)), capabilities, })); - (verifyMessage as jest.Mock).mockImplementationOnce(() => { + mockedRecoverPublicKey.mockImplementationOnce(() => { throw new Error('invalid signature'); }); @@ -374,6 +366,7 @@ describe('matrixMonitorPresenceEpic', () => { available: true, ts: expect.any(Number), caps: { [Capabilities.DELIVERY]: 0, randomCap: 'test' }, + pubkey: expect.any(String), }, { address: partner.address }, ), @@ -686,7 +679,12 @@ describe('deliveredEpic', () => { // set status as available in latest$.presences raiden.store.dispatch( matrixPresence.success( - { userId: partnerMatrix.getUserId()!, available: true, ts: Date.now() }, + { + userId: partnerMatrix.getUserId()!, + available: true, + ts: Date.now(), + pubkey: partner.deps.signer.publicKey as PublicKey, + }, { address: partner.address }, ), ); @@ -741,6 +739,7 @@ describe('deliveredEpic', () => { available: true, ts: Date.now(), caps: { [Capabilities.DELIVERY]: 0 }, + pubkey: partner.deps.signer.publicKey as PublicKey, }, { address: partner.address }, ), diff --git a/raiden-ts/tests/unit/messages.spec.ts b/raiden-ts/tests/unit/messages.spec.ts index 6ebfef56c4..8c03a4dd3b 100644 --- a/raiden-ts/tests/unit/messages.spec.ts +++ b/raiden-ts/tests/unit/messages.spec.ts @@ -22,6 +22,8 @@ import { jsonParse } from '@/utils/data'; import type { Address, Hash, UInt } from '@/utils/types'; import { decode, Signed } from '@/utils/types'; +import { makePublicKey } from '../utils'; + // sign/verify & en/decode to avoid having to duplicate all examples describe('sign/verify, pack & encode/decode ', () => { const signer = new Wallet(Uint8Array.from(Array(32).keys())); @@ -72,6 +74,7 @@ describe('sign/verify, pack & encode/decode ', () => { available: true, // explicitly set IMMUTABLE_METADATA=0 caps: { ...CapsFallback, [Capabilities.IMMUTABLE_METADATA]: 0 }, + pubkey: makePublicKey(), }, { address }, ); diff --git a/raiden-ts/tests/unit/raiden.spec.ts b/raiden-ts/tests/unit/raiden.spec.ts index e5b439a6ee..bec9aa174a 100644 --- a/raiden-ts/tests/unit/raiden.spec.ts +++ b/raiden-ts/tests/unit/raiden.spec.ts @@ -13,7 +13,16 @@ import logging from 'loglevel'; import type { MatrixClient } from 'matrix-js-sdk'; import type { Observable } from 'rxjs'; import { AsyncSubject, BehaviorSubject, EMPTY, of, ReplaySubject, Subject } from 'rxjs'; -import { filter, first, ignoreElements, map, mapTo, mergeMap, mergeMapTo } from 'rxjs/operators'; +import { + filter, + first, + ignoreElements, + map, + mapTo, + mergeMap, + mergeMapTo, + pluck, +} from 'rxjs/operators'; import type { RaidenAction } from '@/actions'; import { raidenConfigUpdate, raidenShutdown, raidenStarted, raidenSynced } from '@/actions'; @@ -71,7 +80,7 @@ import { completeWith, pluckDistinct } from '@/utils/rx'; import type { Int, Secret, UInt } from '@/utils/types'; import { Address, timed } from '@/utils/types'; -import { makeAddress, makeHash, sleep } from '../utils'; +import { makeAddress, makeHash, makePublicKey, sleep } from '../utils'; jest.mock('@ethersproject/providers'); jest.mock('@/db/utils'); @@ -460,10 +469,9 @@ describe('Raiden', () => { userId: 'John Doe', available: true, ts: 12345, + pubkey: makePublicKey(), }, - { - address: partner, - }, + { address: partner }, ), ), ); @@ -478,10 +486,7 @@ describe('Raiden', () => { await expect(raiden.start()).resolves.toBeUndefined(); const payload = raiden.action$ - .pipe( - first(matrixPresence.success.is), - map((e) => e.payload), - ) + .pipe(first(matrixPresence.success.is), pluck('payload')) .toPromise(); raiden.getAvailability(partner); @@ -1205,34 +1210,6 @@ describe('Raiden', () => { }); test('transfer', async () => { - const deps = makeDummyDependencies(); - const raiden: Raiden = new Raiden( - makeInitialState( - { address, network, contractsInfo }, - { tokens: { [token]: tokenNetwork }, channels: { [key]: getChannel() } }, - ), - deps, - combineRaidenEpics([initEpicMock, pfsRequestEpicMock, transferEpicMock]), - dummyReducer, - ); - - const [lockedTransferMessage, fee, secret] = getTransfer(raiden.address); - const signedMessage = await signMessage(deps.signer, lockedTransferMessage); - - function pfsRequestEpicMock(action$: Observable) { - return action$.pipe( - filter(pathFind.request.is), - map((action) => - pathFind.success( - { - paths: [{ path: [raiden.address, partner], fee }], - }, - action.meta, - ), - ), - ); - } - function transferEpicMock(action$: Observable) { return action$.pipe( filter(transfer.request.is), @@ -1248,8 +1225,21 @@ describe('Raiden', () => { ), ); } + + const deps = makeDummyDependencies(); + const raiden: Raiden = new Raiden( + makeInitialState( + { address, network, contractsInfo }, + { tokens: { [token]: tokenNetwork }, channels: { [key]: getChannel() } }, + ), + deps, + combineRaidenEpics([initEpicMock, transferEpicMock]), + dummyReducer, + ); + + const [lockedTransferMessage, fee, secret] = getTransfer(raiden.address); + const signedMessage = await signMessage(deps.signer, lockedTransferMessage); await raiden.start(); - const pathFindRequestPromise = raiden.action$.pipe(first(pathFind.request.is)).toPromise(); const transferRequestPromise = raiden.action$.pipe(first(transfer.request.is)).toPromise(); const transferResult = raiden.transfer(token, partner, 1, { @@ -1259,16 +1249,6 @@ describe('Raiden', () => { }); await expect(transferResult).resolves.toEqual(`sent:${lockedTransferMessage.lock.secrethash}`); - await expect(pathFindRequestPromise).resolves.toEqual( - pathFind.request( - expect.anything(), - expect.objectContaining({ - target: partner, - tokenNetwork: tokenNetwork, - value: One, - }), - ), - ); await expect(transferRequestPromise).resolves.toEqual( transfer.request( expect.objectContaining({ @@ -1276,7 +1256,6 @@ describe('Raiden', () => { target: partner, value: One as UInt<32>, paymentId: lockedTransferMessage.payment_identifier, - metadata: expect.anything(), }), { secrethash: lockedTransferMessage.lock.secrethash, diff --git a/raiden-ts/tests/utils.ts b/raiden-ts/tests/utils.ts index c1e1041537..20115e363a 100644 --- a/raiden-ts/tests/utils.ts +++ b/raiden-ts/tests/utils.ts @@ -2,8 +2,9 @@ import { getAddress } from '@ethersproject/address'; import { hexlify } from '@ethersproject/bytes'; import { keccak256 } from '@ethersproject/keccak256'; import { randomBytes } from '@ethersproject/random'; +import { computePublicKey } from '@ethersproject/signing-key'; -import type { Address, Hash } from '@/utils/types'; +import type { Address, Hash, PublicKey } from '@/utils/types'; /** * Generate a random address @@ -23,6 +24,15 @@ export function makeHash() { return keccak256(randomBytes(32)) as Hash; } +/** + * Generate a random public key + * + * @returns public key + */ +export function makePublicKey() { + return computePublicKey(makeHash()) as PublicKey; +} + /** * Asynchronously wait for some time * From d6c4206b11885d61503d1f3e17c8de1b3aca23d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Vitor=20de=20Lima=20Matos?= Date: Fri, 23 Jul 2021 22:14:02 -0300 Subject: [PATCH 10/11] sdk: introduce metadataToPresence util This helps unify code which validates metadata and create a presence update from it. --- raiden-ts/src/messages/utils.ts | 42 ++++++++++++++++------- raiden-ts/src/services/utils.ts | 25 ++++++-------- raiden-ts/src/transfers/mediate/epics.ts | 8 ++--- raiden-ts/src/transfers/utils.ts | 24 +++++++------ raiden-ts/src/transport/epics/presence.ts | 12 ------- 5 files changed, 58 insertions(+), 53 deletions(-) diff --git a/raiden-ts/src/messages/utils.ts b/raiden-ts/src/messages/utils.ts index 3d39b2308c..28ed40782c 100644 --- a/raiden-ts/src/messages/utils.ts +++ b/raiden-ts/src/messages/utils.ts @@ -14,9 +14,9 @@ import logging from 'loglevel'; import type { BalanceProof } from '../channels/types'; import { Capabilities, LocksrootZero } from '../constants'; -import type { matrixPresence } from '../transport/actions'; +import { matrixPresence } from '../transport/actions'; import type { Caps } from '../transport/types'; -import { getCap } from '../transport/utils'; +import { getCap, parseCaps } from '../transport/utils'; import type { RaidenEpicDeps } from '../types'; import { assert } from '../utils'; import { encode, jsonParse, jsonStringify } from '../utils/data'; @@ -422,6 +422,28 @@ export function isMessageReceivedOfType(messageCodecs: C | C[ : messageCodecs.is(action.payload.message)); } +/** + * @param metadata - to convert to presence + * @returns presence for metadata, assuming node is available + */ +export function metadataToPresence(metadata: AddressMetadata): matrixPresence.success { + const pubkey = recoverPublicKey( + arrayify(hashMessage(metadata.user_id)), + metadata.displayname, + ) as PublicKey; + const address = computeAddress(pubkey) as Address; + return matrixPresence.success( + { + userId: metadata.user_id, + available: true, + ts: Date.now(), + caps: parseCaps(metadata.capabilities), + pubkey, + }, + { address }, + ); +} + /** * Validates metadata was signed by address * @@ -429,25 +451,21 @@ export function isMessageReceivedOfType(messageCodecs: C | C[ * @param address - Peer's address * @param opts - Options * @param opts.log - Logger instance - * @returns Metadata iff it's valid and was signed by address + * @returns presence iff metadata is valid and was signed by address */ export function validateAddressMetadata( metadata: AddressMetadata | undefined, address: Address, { log }: Partial> = {}, -): [metadata: AddressMetadata, pubkey: PublicKey] | undefined { +): matrixPresence.success | undefined { if (!metadata) return; try { - const pubkey = recoverPublicKey( - arrayify(hashMessage(metadata.user_id)), - metadata.displayname, - ) as PublicKey; - const recoveredAddress = computeAddress(pubkey) as Address; - assert(recoveredAddress === address, [ + const presence = metadataToPresence(metadata); + assert(presence.meta.address === address, [ 'Wrong signature', - { expected: address, received: recoveredAddress }, + { expected: address, recovered: presence.meta.address }, ]); - return [metadata, pubkey]; + return presence; } catch (error) { log?.warn('Invalid address metadata', { address, metadata, error }); } diff --git a/raiden-ts/src/services/utils.ts b/raiden-ts/src/services/utils.ts index 27bafce09d..abc123df9d 100644 --- a/raiden-ts/src/services/utils.ts +++ b/raiden-ts/src/services/utils.ts @@ -11,14 +11,13 @@ import { catchError, first, map, mergeMap, toArray } from 'rxjs/operators'; import type { ServiceRegistry } from '../contracts'; import { AddressMetadata } from '../messages/types'; import { MessageTypeId, validateAddressMetadata } from '../messages/utils'; -import type { Caps } from '../transport/types'; -import { parseCaps } from '../transport/utils'; +import type { matrixPresence } from '../transport/actions'; import type { RaidenEpicDeps } from '../types'; import { encode, jsonParse } from '../utils/data'; import { assert, ErrorCodes, networkErrors, RaidenError } from '../utils/error'; import { LruCache } from '../utils/lru'; import { retryAsync$ } from '../utils/rx'; -import type { PublicKey, Signature, Signed } from '../utils/types'; +import type { Signature, Signed } from '../utils/types'; import { Address, decode, UInt } from '../utils/types'; import type { IOU, PFS } from './types'; @@ -213,30 +212,28 @@ export function pfsListInfo( } /** - * @param peer - Peer address to fetch presence for + * @param address - Peer address to fetch presence for * @param pfsAddrOrUrl - PFS/service address to fetch presence from * @param deps - Epics dependencies subset * @param deps.serviceRegistryContract - Contract instance * @returns Observable to peer's presence or error */ export function getPresenceFromService$( - peer: Address, + address: Address, pfsAddrOrUrl: string, deps: Pick, -): Observable<{ user_id: string; capabilities: Caps; pubkey: PublicKey }> { +): Observable { const { serviceRegistryContract } = deps; return defer(async () => pfsAddressUrl(pfsAddrOrUrl, { serviceRegistryContract })).pipe( - mergeMap((url) => fromFetch(`${url}/api/v1/address/${peer}/metadata`)), + mergeMap((url) => fromFetch(`${url}/api/v1/address/${address}/metadata`)), mergeMap(async (res) => res.json()), map((json) => { try { - const presence = decode(AddressMetadata, json); - const meta = validateAddressMetadata(presence, peer, deps); - assert(meta, ['Invalid metadata signature', { peer, presence }]); - const [, pubkey] = meta; - const capabilities = parseCaps(presence.capabilities); - assert(capabilities, ['Invalid capabilities format', presence.capabilities]); - return { ...presence, capabilities, pubkey }; + const metadata = decode(AddressMetadata, json); + const presence = validateAddressMetadata(metadata, address, deps); + assert(presence, ['Invalid metadata signature', { peer: address, presence: metadata }]); + assert(presence.payload.caps, ['Invalid capabilities format', metadata.capabilities]); + return presence; } catch (err) { try { const { errors: msg, ...details } = decode(ServiceError, json); diff --git a/raiden-ts/src/transfers/mediate/epics.ts b/raiden-ts/src/transfers/mediate/epics.ts index 4f71933edd..22d5bc2dab 100644 --- a/raiden-ts/src/transfers/mediate/epics.ts +++ b/raiden-ts/src/transfers/mediate/epics.ts @@ -8,7 +8,7 @@ import type { RaidenConfig } from '../../config'; import { Capabilities } from '../../constants'; import { Metadata } from '../../messages/types'; import type { RaidenState } from '../../state'; -import { getCap, parseCaps } from '../../transport/utils'; +import { getCap } from '../../transport/utils'; import type { RaidenEpicDeps } from '../../types'; import type { Address, Int } from '../../utils/types'; import { decode, isntNil } from '../../utils/types'; @@ -83,10 +83,10 @@ function findValidPartner( continue; } - const outPartnerMetadata = searchValidMetadata(address_metadata, outPartner); + const outPartnerPresence = searchValidMetadata(address_metadata, outPartner); let metadata = message.metadata; // pass through metadata // iff partner requires a clear route (to be first address), clear original metadata - if (!getCap(parseCaps(outPartnerMetadata?.capabilities), Capabilities.IMMUTABLE_METADATA)) + if (!getCap(outPartnerPresence?.payload.caps, Capabilities.IMMUTABLE_METADATA)) metadata = clearMetadataRoute(address, metadata); return { @@ -95,7 +95,7 @@ function findValidPartner( // on a transfer.request, fee is *added* to the value to get final sent amount, // therefore here it needs to contain a negative fee, which we will "earn" instead of pay fee: fee.mul(-1) as Int<32>, - userId: outPartnerMetadata?.user_id, + userId: outPartnerPresence?.payload.userId, metadata, }; } diff --git a/raiden-ts/src/transfers/utils.ts b/raiden-ts/src/transfers/utils.ts index 0597ed0cad..57d4131b5b 100644 --- a/raiden-ts/src/transfers/utils.ts +++ b/raiden-ts/src/transfers/utils.ts @@ -30,7 +30,7 @@ import type { Paths } from '../services/types'; import type { RaidenState } from '../state'; import type { matrixPresence } from '../transport/actions'; import type { Caps, Via } from '../transport/types'; -import { getCap, parseCaps } from '../transport/utils'; +import { getCap } from '../transport/utils'; import { assert } from '../utils'; import { encode, jsonParse, jsonStringify } from '../utils/data'; import type { Address, Hash, Int, PrivateKey, Secret, UInt } from '../utils/types'; @@ -326,9 +326,9 @@ export function metadataFromPaths( partnerUserId = target.payload.userId; partnerCaps = target.payload.caps; } else { - const partnerMetadata = searchValidMetadata(viaPath.address_metadata, partner); - partnerUserId = partnerMetadata?.user_id; - partnerCaps = parseCaps(partnerMetadata?.capabilities); + const partnerPresence = searchValidMetadata(viaPath.address_metadata, partner); + partnerUserId = partnerPresence?.payload.userId; + partnerCaps = partnerPresence?.payload.caps; } const via: Via = { userId: partnerUserId }; @@ -387,12 +387,13 @@ export function decryptSecretFromMetadata( export function searchValidMetadata( addressMetadata: RouteMetadata['address_metadata'], address: Address, -) { - return validateAddressMetadata( - // support address_metadata keys being both lowercase and checksummed addresses - addressMetadata?.[address] ?? addressMetadata?.[address.toLowerCase()], - address, - )?.[0]; +): matrixPresence.success | undefined { + // support address_metadata keys being both lowercase and checksummed addresses + const metadata = addressMetadata?.[address] ?? addressMetadata?.[address.toLowerCase()]; + if (metadata) { + const presence = validateAddressMetadata(metadata, address); + if (presence) return presence; + } } /** @@ -411,6 +412,7 @@ export function searchValidViaAddress( } catch (e) {} if (!decoded || !address) return; for (const { address_metadata } of decoded.routes) { - if ((userId = searchValidMetadata(address_metadata, address)?.user_id)) return { userId }; + if ((userId = searchValidMetadata(address_metadata, address)?.payload.userId)) + return { userId }; } } diff --git a/raiden-ts/src/transport/epics/presence.ts b/raiden-ts/src/transport/epics/presence.ts index edca074f8c..c33bcbed18 100644 --- a/raiden-ts/src/transport/epics/presence.ts +++ b/raiden-ts/src/transport/epics/presence.ts @@ -69,18 +69,6 @@ function searchAddressPresence$( first(), ); }), - map(({ user_id: userId, capabilities, pubkey }) => - matrixPresence.success( - { - userId, - available: true, - ts: Date.now(), - caps: capabilities, - pubkey, - }, - { address }, - ), - ), catchError((err) => of(matrixPresence.failure(err, { address }))), ); } From 68abd347ca7a885729e13a9e17b99fd64053da56 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Vitor=20de=20Lima=20Matos?= Date: Mon, 26 Jul 2021 11:34:32 -0300 Subject: [PATCH 11/11] sdk: address some styling feedback --- raiden-ts/src/transfers/epics/init.ts | 28 +++++++++++++-------------- raiden-ts/src/transfers/utils.ts | 3 ++- 2 files changed, 16 insertions(+), 15 deletions(-) diff --git a/raiden-ts/src/transfers/epics/init.ts b/raiden-ts/src/transfers/epics/init.ts index 9061119dde..4af57cb2ef 100644 --- a/raiden-ts/src/transfers/epics/init.ts +++ b/raiden-ts/src/transfers/epics/init.ts @@ -212,21 +212,21 @@ export function transferRequestResolveEpic( config$, ), map(([route, targetPresence, { encryptSecret }]) => { - const resolved = - (action.payload.encryptSecret ?? encryptSecret) && action.payload.secret - ? metadataFromPaths(route.payload.paths, targetPresence, { - secret: action.payload.secret, - amount: action.payload.value, - payment_identifier: action.payload.paymentId, - }) - : metadataFromPaths(route.payload.paths, targetPresence); - return transfer.request( - { - ...omit(action.payload, ['paths', 'pfs', 'encryptSecret'] as const), - ...resolved, - }, - action.meta, + let encryptSecretOptions; + if ((action.payload.encryptSecret ?? encryptSecret) && action.payload.secret) + encryptSecretOptions = { + secret: action.payload.secret, + amount: action.payload.value, + payment_identifier: action.payload.paymentId, + }; + const resolvedPayload = metadataFromPaths( + route.payload.paths, + targetPresence, + encryptSecretOptions, ); + const restPayload = omit(action.payload, ['paths', 'pfs', 'encryptSecret'] as const); + const requestOptions = { ...restPayload, ...resolvedPayload }; + return transfer.request(requestOptions, action.meta); }), catchError((err) => of(transfer.failure(err, action.meta))), ), diff --git a/raiden-ts/src/transfers/utils.ts b/raiden-ts/src/transfers/utils.ts index 57d4131b5b..0bd050640b 100644 --- a/raiden-ts/src/transfers/utils.ts +++ b/raiden-ts/src/transfers/utils.ts @@ -321,7 +321,8 @@ export function metadataFromPaths( const viaPath = paths[0]; const fee = viaPath.fee; const partner = viaPath.path[1]; // we're first address in route, partner is 2nd - let partnerUserId: string | undefined, partnerCaps: Caps | null | undefined; + let partnerUserId: string | undefined; + let partnerCaps: Caps | null | undefined; if (partner === target.meta.address) { partnerUserId = target.payload.userId; partnerCaps = target.payload.caps;