diff --git a/Makefile b/Makefile index b05d228b4..b1734753c 100644 --- a/Makefile +++ b/Makefile @@ -34,11 +34,11 @@ log_finish=@echo $$((`date "+%s"` - `cat $(startTime)`)) > $(totalTime); rm $(st # Build Shortcuts default: dev -dev: messaging node router duet trio test-runner-js +dev: messaging node router duet trio auction test-runner-js prod: messaging-prod node-prod router-prod test-runner all: dev prod iframe-app -messaging: auth-js ethprovider messaging-proxy nats +messaging: auth-bundle ethprovider messaging-proxy nats messaging-prod: auth-img messaging-proxy nats node: messaging server-node-img @@ -49,6 +49,7 @@ router-prod: node-prod router-img duet: messaging server-node-js trio: messaging server-node-js router-js +auction: messaging server-node-js router-js ######################################## # Command & Control Shortcuts @@ -85,6 +86,13 @@ restart-trio: stop-trio stop-trio: @bash ops/stop.sh trio +start-auction: auction + @bash ops/start-auction.sh +restart-auction: stop-auction + @bash ops/start-auction.sh +stop-auction: + @bash ops/stop.sh auction + start-chains: ethprovider @bash ops/start-chains.sh restart-chains: stop-chains diff --git a/modules/auth/ops/webpack.config.js b/modules/auth/ops/webpack.config.js index 3e1d87be4..eed726a68 100644 --- a/modules/auth/ops/webpack.config.js +++ b/modules/auth/ops/webpack.config.js @@ -1,3 +1,4 @@ +const CopyPlugin = require("copy-webpack-plugin"); const path = require("path"); module.exports = { @@ -51,8 +52,25 @@ module.exports = { }, }, }, + { + test: /\.wasm$/, + type: "javascript/auto", + exclude: /node_modules/, + use: { loader: "wasm-loader" }, + }, ], }, + plugins: [ + new CopyPlugin({ + patterns: [ + { + from: path.join(__dirname, "../../../node_modules/@connext/vector-merkle-tree/dist/node/index_bg.wasm"), + to: path.join(__dirname, "../dist/index_bg.wasm"), + }, + ], + }), + ], + stats: { warnings: false }, }; diff --git a/modules/auth/package.json b/modules/auth/package.json index 0f5bba8eb..bce4ef846 100644 --- a/modules/auth/package.json +++ b/modules/auth/package.json @@ -12,8 +12,8 @@ "test": "ts-mocha --check-leaks --exit --timeout 60000 'src/**/*.spec.ts'" }, "dependencies": { - "@connext/vector-types": "0.2.5-beta.18", - "@connext/vector-utils": "0.2.5-beta.18", + "@connext/vector-types": "0.3.0-beta.2", + "@connext/vector-utils": "0.3.0-beta.2", "@sinclair/typebox": "0.12.7", "crypto": "1.0.1", "fastify": "3.13.0", diff --git a/modules/browser-node/ops/webpack.config.js b/modules/browser-node/ops/webpack.config.js new file mode 100644 index 000000000..22134e210 --- /dev/null +++ b/modules/browser-node/ops/webpack.config.js @@ -0,0 +1,74 @@ +const CopyPlugin = require("copy-webpack-plugin"); +const path = require("path"); + +module.exports = { + mode: "development", + target: "node", + + context: path.join(__dirname, ".."), + + entry: path.join(__dirname, "../src/index.ts"), + + node: { + __filename: false, + __dirname: false, + }, + + resolve: { + mainFields: ["main", "module"], + extensions: [".js", ".wasm", ".ts", ".json"], + symlinks: false, + }, + + output: { + path: path.join(__dirname, "../dist"), + filename: "bundle.js", + }, + + module: { + rules: [ + { + test: /\.js$/, + exclude: /node_modules/, + use: { + loader: "babel-loader", + options: { + presets: ["@babel/env"], + }, + }, + }, + { + test: /\.ts$/, + exclude: /node_modules/, + use: { + loader: "ts-loader", + options: { + configFile: path.join(__dirname, "../tsconfig.json"), + }, + }, + }, + { + test: /\.wasm$/, + type: "javascript/auto", + use: "wasm-loader", + }, + ], + }, + + plugins: [ + new CopyPlugin({ + patterns: [ + { + from: path.join(__dirname, "../node_modules/@connext/vector-contracts/dist/pure-evm_bg.wasm"), + to: path.join(__dirname, "../dist/pure-evm_bg.wasm"), + }, + { + from: path.join(__dirname, "../../../node_modules/@connext/vector-merkle-tree/dist/node/index_bg.wasm"), + to: path.join(__dirname, "../dist/index_bg.wasm"), + }, + ], + }), + ], + + stats: { warnings: false }, +}; diff --git a/modules/browser-node/ops/webpack.config.ts b/modules/browser-node/ops/webpack.config.ts deleted file mode 100644 index ecafff4c4..000000000 --- a/modules/browser-node/ops/webpack.config.ts +++ /dev/null @@ -1,25 +0,0 @@ -import * as path from "path"; - -import * as webpack from "webpack"; - -const config: webpack.Configuration = { - entry: "./src/index.ts", - module: { - rules: [ - { - test: /\.tsx?$/, - use: "ts-loader", - exclude: /node_modules/, - }, - ], - }, - resolve: { - extensions: [".tsx", ".ts", ".js"], - }, - output: { - filename: "bundle.js", - path: path.resolve(__dirname, "dist"), - }, -}; - -export default config; diff --git a/modules/browser-node/package.json b/modules/browser-node/package.json index f7066a0f2..0aa8f2dd1 100644 --- a/modules/browser-node/package.json +++ b/modules/browser-node/package.json @@ -1,6 +1,6 @@ { "name": "@connext/vector-browser-node", - "version": "0.2.5-beta.18", + "version": "0.3.0-beta.2", "author": "", "license": "ISC", "description": "", @@ -12,15 +12,15 @@ "types" ], "scripts": { - "build": "rm -rf dist && tsc", + "build": "rm -rf dist && tsc && webpack --config ops/webpack.config.js", "start": "node dist/index.js", "test": "nyc ts-mocha --bail --check-leaks --exit --timeout 60000 'src/**/*.spec.ts'" }, "dependencies": { - "@connext/vector-contracts": "0.2.5-beta.18", - "@connext/vector-engine": "0.2.5-beta.18", - "@connext/vector-types": "0.2.5-beta.18", - "@connext/vector-utils": "0.2.5-beta.18", + "@connext/vector-contracts": "0.3.0-beta.2", + "@connext/vector-engine": "0.3.0-beta.2", + "@connext/vector-types": "0.3.0-beta.2", + "@connext/vector-utils": "0.3.0-beta.2", "@ethersproject/address": "5.2.0", "@ethersproject/bignumber": "5.2.0", "@ethersproject/constants": "5.2.0", diff --git a/modules/browser-node/src/index.ts b/modules/browser-node/src/index.ts index 771aca614..271f8aa39 100644 --- a/modules/browser-node/src/index.ts +++ b/modules/browser-node/src/index.ts @@ -24,7 +24,6 @@ import { constructRpcRequest, hydrateProviders, NatsMessagingService } from "@co import pino, { BaseLogger } from "pino"; import { BrowserStore } from "./services/store"; -import { BrowserLockService } from "./services/lock"; import { DirectProvider, IframeChannelProvider, IRpcChannelProvider } from "./channelProvider"; import { BrowserNodeError } from "./errors"; export * from "./constants"; @@ -108,11 +107,6 @@ export class BrowserNode implements INodeService { config.signer.publicIdentifier, config.logger.child({ module: "BrowserStore" }), ); - const lock = new BrowserLockService( - config.signer.publicIdentifier, - messaging, - config.logger.child({ module: "BrowserLockService" }), - ); const chainService = new VectorChainService( store, chainJsonProviders, @@ -146,7 +140,6 @@ export class BrowserNode implements INodeService { const engine = await VectorEngine.connect( messaging, - lock, store, config.signer, chainService, @@ -187,9 +180,18 @@ export class BrowserNode implements INodeService { }); const auth = await this.channelProvider.send(rpc); this.logger.info({ method, response: auth }, "Received response from auth method"); + const [nodeConfig] = await this.getConfig(); this.publicIdentifier = nodeConfig.publicIdentifier; this.signerAddress = nodeConfig.signerAddress; + this.logger.debug({ method }, "Method complete"); + } + + async channelSetup(params: { routerPublicIdentifier: string }): Promise { + const method = "channelSetup"; + this.logger.debug({ method }, "Channel Setup"); + + this.routerPublicIdentifier = params.routerPublicIdentifier; this.logger.info( { supportedChains: this.supportedChains, routerPublicIdentifier: this.routerPublicIdentifier, method }, "Checking for existing channels", @@ -603,6 +605,18 @@ export class BrowserNode implements INodeService { } } + async runAuction( + params: OptionalPublicIdentifier, + ): Promise> { + const rpc = constructRpcRequest(ChannelRpcMethods.chan_runAuction, params); + try { + const { routerPublicIdentifier, swapRate, totalFee, quote } = await this.send(rpc); + return Result.ok({ routerPublicIdentifier, swapRate, totalFee, quote }); + } catch (e) { + return Result.fail(e); + } + } + async send(payload: EngineParams.RpcRequest): Promise { return this.channelProvider!.send(payload); } diff --git a/modules/browser-node/src/services/lock.ts b/modules/browser-node/src/services/lock.ts deleted file mode 100644 index 7d1698e27..000000000 --- a/modules/browser-node/src/services/lock.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { ILockService, IMessagingService, Result, jsonifyError } from "@connext/vector-types"; -import { BaseLogger } from "pino"; - -import { BrowserNodeLockError } from "../errors"; - -export class BrowserLockService implements ILockService { - constructor( - private readonly publicIdentifier: string, - private readonly messagingService: IMessagingService, - private readonly log: BaseLogger, - ) {} - - async acquireLock(lockName: string, isAlice?: boolean, counterpartyPublicIdentifier?: string): Promise { - if (!counterpartyPublicIdentifier) { - throw new BrowserNodeLockError(BrowserNodeLockError.reasons.CounterpartyIdentifierMissing, lockName); - } - if (isAlice) { - throw new BrowserNodeLockError(BrowserNodeLockError.reasons.CannotBeAlice, lockName); - } - - const res = await this.messagingService.sendLockMessage( - Result.ok({ type: "acquire", lockName }), - counterpartyPublicIdentifier!, - this.publicIdentifier, - ); - if (res.isError) { - throw new BrowserNodeLockError(BrowserNodeLockError.reasons.AcquireMessageFailed, lockName, "", { - error: jsonifyError(res.getError()!), - }); - } - const { lockValue } = res.getValue(); - if (!lockValue) { - throw new BrowserNodeLockError(BrowserNodeLockError.reasons.SentMessageAcquisitionFailed, lockName); - } - this.log.debug({ method: "acquireLock", lockName, lockValue }, "Acquired lock"); - return lockValue; - } - - async releaseLock( - lockName: string, - lockValue: string, - isAlice?: boolean, - counterpartyPublicIdentifier?: string, - ): Promise { - if (!counterpartyPublicIdentifier) { - throw new BrowserNodeLockError(BrowserNodeLockError.reasons.CounterpartyIdentifierMissing, lockName, lockValue); - } - if (isAlice) { - throw new BrowserNodeLockError(BrowserNodeLockError.reasons.CannotBeAlice, lockName, lockValue); - } - - const result = await this.messagingService.sendLockMessage( - Result.ok({ type: "release", lockName, lockValue }), - counterpartyPublicIdentifier!, - this.publicIdentifier, - ); - if (result.isError) { - throw new BrowserNodeLockError(BrowserNodeLockError.reasons.ReleaseMessageFailed, lockName, "", { - error: jsonifyError(result.getError()!), - }); - } - this.log.debug({ method: "releaseLock", lockName, lockValue }, "Released lock"); - } -} diff --git a/modules/browser-node/src/services/store.ts b/modules/browser-node/src/services/store.ts index 1126994db..97d55d921 100644 --- a/modules/browser-node/src/services/store.ts +++ b/modules/browser-node/src/services/store.ts @@ -1,5 +1,6 @@ import { ChannelDispute, + ChannelUpdate, CoreChannelState, CoreTransferState, FullChannelState, @@ -42,6 +43,7 @@ const getStoreName = (publicIdentifier: string) => { }; const NON_NAMESPACED_STORE = "VectorIndexedDBDatabase"; class VectorIndexedDBDatabase extends Dexie { + updates: Dexie.Table; channels: Dexie.Table; transfers: Dexie.Table; transactions: Dexie.Table; @@ -111,29 +113,38 @@ class VectorIndexedDBDatabase extends Dexie { // Using a temp table (transactions2) to migrate which column is the primary key // (transactionHash -> id) - this.version(5).stores({ - withdrawCommitment: "transferId,channelAddress,transactionHash", - transactions2: "id, transactionHash", - }).upgrade(async tx => { - const transactions = await tx.table("transactions").toArray(); - await tx.table("transactions2").bulkAdd(transactions); - }); + this.version(5) + .stores({ + withdrawCommitment: "transferId,channelAddress,transactionHash", + transactions2: "id, transactionHash", + }) + .upgrade(async (tx) => { + const transactions = await tx.table("transactions").toArray(); + await tx.table("transactions2").bulkAdd(transactions); + }); this.version(6).stores({ - transactions: null + transactions: null, }); - this.version(7).stores({ - transactions: "id, transactionHash" - }).upgrade(async tx => { - const transactions2 = await tx.table("transactions2").toArray(); - await tx.table("transactions").bulkAdd(transactions2); - }); + this.version(7) + .stores({ + transactions: "id, transactionHash", + }) + .upgrade(async (tx) => { + const transactions2 = await tx.table("transactions2").toArray(); + await tx.table("transactions").bulkAdd(transactions2); + }); this.version(8).stores({ - transactions2: null + transactions2: null, + }); + + this.version(9).stores({ + updates: "id.id, [channelAddress+nonce]", }); + this.updates = this.table("updates"); this.channels = this.table("channels"); this.transfers = this.table("transfers"); this.transactions = this.table("transactions"); @@ -245,8 +256,9 @@ export class BrowserStore implements IEngineStore, IChainServiceStore { } async saveChannelState(channelState: FullChannelState, transfer?: FullTransferState): Promise { - await this.db.transaction("rw", this.db.channels, this.db.transfers, async () => { + await this.db.transaction("rw", this.db.channels, this.db.transfers, this.db.updates, async () => { await this.db.channels.put(channelState); + await this.db.updates.put(channelState.latestUpdate); if (channelState.latestUpdate.type === UpdateType.create) { await this.db.transfers.put({ ...transfer!, @@ -264,6 +276,11 @@ export class BrowserStore implements IEngineStore, IChainServiceStore { }); } + async getUpdateById(id: string): Promise { + const update = await this.db.updates.get(id); + return update; + } + async getChannelStates(): Promise { const channels = await this.db.channels.toArray(); return channels; @@ -356,7 +373,7 @@ export class BrowserStore implements IEngineStore, IChainServiceStore { } async getTransactionById(onchainTransactionId: string): Promise { - return await this.db.transactions.get({ id: onchainTransactionId }) + return await this.db.transactions.get({ id: onchainTransactionId }); } async getActiveTransactions(): Promise { @@ -383,30 +400,33 @@ export class BrowserStore implements IEngineStore, IChainServiceStore { attempts.push({ // TransactionResponse fields (defined when submitted) gasLimit: response.gasLimit.toString(), - gasPrice: response.gasPrice.toString(), + gasPrice: response.gasPrice.toString(), transactionHash: response.hash, createdAt: new Date(), } as StoredTransactionAttempt); - await this.db.transactions.put({ - id: onchainTransactionId, - - //// Helper fields - channelAddress, - status: StoredTransactionStatus.submitted, - reason, - - //// Provider fields - // Minimum fields (should always be defined) - to: response.to!, - from: response.from, - data: response.data, - value: response.value.toString(), - chainId: response.chainId, - nonce: response.nonce, - attempts, - } as StoredTransaction, onchainTransactionId); + await this.db.transactions.put( + { + id: onchainTransactionId, + + //// Helper fields + channelAddress, + status: StoredTransactionStatus.submitted, + reason, + + //// Provider fields + // Minimum fields (should always be defined) + to: response.to!, + from: response.from, + data: response.data, + value: response.value.toString(), + chainId: response.chainId, + nonce: response.nonce, + attempts, + } as StoredTransaction, + onchainTransactionId, + ); } async saveTransactionReceipt(onchainTransactionId: string, receipt: TransactionReceipt): Promise { diff --git a/modules/contracts/package.json b/modules/contracts/package.json index 61460817d..6725cd0de 100644 --- a/modules/contracts/package.json +++ b/modules/contracts/package.json @@ -1,6 +1,6 @@ { "name": "@connext/vector-contracts", - "version": "0.2.5-beta.18", + "version": "0.3.0-beta.2", "license": "ISC", "description": "Smart contracts powering Connext's minimalist channel platform", "keywords": [ @@ -29,8 +29,8 @@ }, "dependencies": { "@connext/pure-evm-wasm": "0.1.4", - "@connext/vector-types": "0.2.5-beta.18", - "@connext/vector-utils": "0.2.5-beta.18", + "@connext/vector-types": "0.3.0-beta.2", + "@connext/vector-utils": "0.3.0-beta.2", "@ethersproject/abi": "5.2.0", "@ethersproject/abstract-provider": "5.2.0", "@ethersproject/abstract-signer": "5.2.0", diff --git a/modules/contracts/src.ts/services/ethReader.spec.ts b/modules/contracts/src.ts/services/ethReader.spec.ts index 0abf25a39..a1e9044f8 100644 --- a/modules/contracts/src.ts/services/ethReader.spec.ts +++ b/modules/contracts/src.ts/services/ethReader.spec.ts @@ -48,6 +48,7 @@ describe("ethReader", () => { const _provider = createStubInstance(JsonRpcProvider); _provider.getTransaction.resolves(_txResponse); + _provider.getBlockNumber.resolves(10); provider1337 = _provider; provider1338 = _provider; diff --git a/modules/contracts/src.ts/services/ethReader.ts b/modules/contracts/src.ts/services/ethReader.ts index cf5f1531f..ab0f430de 100644 --- a/modules/contracts/src.ts/services/ethReader.ts +++ b/modules/contracts/src.ts/services/ethReader.ts @@ -26,6 +26,8 @@ import { CoreChannelState, CoreTransferState, TransferDispute, + getConfirmationsForChain, + TEST_CHAIN_IDS, } from "@connext/vector-types"; import axios from "axios"; import { encodeBalance, encodeTransferResolver, encodeTransferState } from "@connext/vector-utils"; @@ -113,13 +115,19 @@ export class EthereumChainReader implements IVectorChainReader { async getChannelDispute( channelAddress: string, chainId: number, + blockTag?: Result, ): Promise> { const provider = this.chainProviders[chainId]; if (!provider) { return Result.fail(new ChainError(ChainError.reasons.ProviderNotFound)); } - const code = await this.getCode(channelAddress, chainId); + const block = blockTag ?? (await this.getSafeBlockNumber(chainId)); + if (block.isError) { + return Result.fail(block.getError()!); + } + + const code = await this.getCode(channelAddress, chainId, block); if (code.isError) { return Result.fail(code.getError()!); } @@ -130,7 +138,9 @@ export class EthereumChainReader implements IVectorChainReader { } return await this.retryWrapper(chainId, async () => { try { - const dispute = await new Contract(channelAddress, VectorChannel.abi, provider).getChannelDispute(); + const dispute = await new Contract(channelAddress, VectorChannel.abi, provider).getChannelDispute({ + blockTag: block.getValue(), + }); if (dispute.channelStateHash === HashZero) { return Result.ok(undefined); } @@ -152,12 +162,17 @@ export class EthereumChainReader implements IVectorChainReader { transferRegistry: string, chainId: number, bytecode?: string, + blockTag?: Result, ): Promise> { + const block = blockTag ?? (await this.getSafeBlockNumber(chainId)); + if (block.isError) { + return Result.fail(block.getError()!); + } return await this.retryWrapper(chainId, async () => { let registry = this.transferRegistries.get(chainId.toString())!; if (!this.transferRegistries.has(chainId.toString())) { // Registry for chain not loaded, load into memory - const loadRes = await this.loadRegistry(transferRegistry, chainId, bytecode); + const loadRes = await this.loadRegistry(transferRegistry, chainId, bytecode, block); if (loadRes.isError) { return Result.fail(loadRes.getError()!); } @@ -183,12 +198,17 @@ export class EthereumChainReader implements IVectorChainReader { transferRegistry: string, chainId: number, bytecode?: string, + blockTag?: Result, ): Promise> { + const block = blockTag ?? (await this.getSafeBlockNumber(chainId)); + if (block.isError) { + return Result.fail(block.getError()!); + } return await this.retryWrapper(chainId, async () => { let registry = this.transferRegistries.get(chainId.toString()); if (!registry) { // Registry for chain not loaded, load into memory - const loadRes = await this.loadRegistry(transferRegistry, chainId, bytecode); + const loadRes = await this.loadRegistry(transferRegistry, chainId, bytecode, block); if (loadRes.isError) { return Result.fail(loadRes.getError()!); } @@ -213,12 +233,17 @@ export class EthereumChainReader implements IVectorChainReader { transferRegistry: string, chainId: number, bytecode?: string, + blockTag?: Result, ): Promise> { + const block = blockTag ?? (await this.getSafeBlockNumber(chainId)); + if (block.isError) { + return Result.fail(block.getError()!); + } return await this.retryWrapper(chainId, async () => { let registry = this.transferRegistries.get(chainId.toString()); if (!registry) { // Registry for chain not loaded, load into memory - const loadRes = await this.loadRegistry(transferRegistry, chainId, bytecode); + const loadRes = await this.loadRegistry(transferRegistry, chainId, bytecode, block); if (loadRes.isError) { return Result.fail(loadRes.getError()!); } @@ -228,15 +253,25 @@ export class EthereumChainReader implements IVectorChainReader { }); } - async getChannelFactoryBytecode(channelFactoryAddress: string, chainId: number): Promise> { + async getChannelFactoryBytecode( + channelFactoryAddress: string, + chainId: number, + blockTag?: Result, + ): Promise> { const provider = this.chainProviders[chainId]; if (!provider) { return Result.fail(new ChainError(ChainError.reasons.ProviderNotFound)); } + const block = blockTag ?? (await this.getSafeBlockNumber(chainId)); + if (block.isError) { + return Result.fail(block.getError()!); + } return await this.retryWrapper(chainId, async () => { try { const factory = new Contract(channelFactoryAddress, ChannelFactory.abi, provider); - const proxyBytecode = await factory.getProxyCreationCode(); + const proxyBytecode = await factory.getProxyCreationCode({ + blockTag: block.getValue(), + }); return Result.ok(proxyBytecode); } catch (e) { return Result.fail(e); @@ -247,15 +282,23 @@ export class EthereumChainReader implements IVectorChainReader { async getChannelMastercopyAddress( channelFactoryAddress: string, chainId: number, + blockTag?: Result, ): Promise> { const provider = this.chainProviders[chainId]; if (!provider) { return Result.fail(new ChainError(ChainError.reasons.ProviderNotFound)); } + const block = blockTag ?? (await this.getSafeBlockNumber(chainId)); + if (block.isError) { + return Result.fail(block.getError()!); + } + return await this.retryWrapper(chainId, async () => { try { const factory = new Contract(channelFactoryAddress, ChannelFactory.abi, provider); - const mastercopy = await factory.getMastercopy(); + const mastercopy = await factory.getMastercopy({ + blockTag: block.getValue(), + }); return Result.ok(mastercopy); } catch (e) { return Result.fail(e); @@ -267,13 +310,19 @@ export class EthereumChainReader implements IVectorChainReader { channelAddress: string, chainId: number, assetId: string, + blockTag?: Result, ): Promise> { const provider = this.chainProviders[chainId]; if (!provider) { return Result.fail(new ChainError(ChainError.reasons.ProviderNotFound)); } + const block = blockTag ?? (await this.getSafeBlockNumber(chainId)); + if (block.isError) { + console.log("***** failed to fetch block"); + return Result.fail(block.getError()!); + } return await this.retryWrapper(chainId, async () => { - const code = await this.getCode(channelAddress, chainId); + const code = await this.getCode(channelAddress, chainId, block); if (code.isError) { return Result.fail(code.getError()!); } @@ -284,7 +333,9 @@ export class EthereumChainReader implements IVectorChainReader { const channelContract = new Contract(channelAddress, ChannelMastercopy.abi, provider); try { - const totalDepositsAlice = await channelContract.getTotalDepositsAlice(assetId); + const totalDepositsAlice = await channelContract.getTotalDepositsAlice(assetId, { + blockTag: block.getValue(), + }); return Result.ok(totalDepositsAlice); } catch (e) { return Result.fail(e); @@ -296,24 +347,31 @@ export class EthereumChainReader implements IVectorChainReader { channelAddress: string, chainId: number, assetId: string, + blockTag?: Result, ): Promise> { const provider = this.chainProviders[chainId]; if (!provider) { return Result.fail(new ChainError(ChainError.reasons.ProviderNotFound)); } + const block = blockTag ?? (await this.getSafeBlockNumber(chainId)); + if (block.isError) { + return Result.fail(block.getError()!); + } return await this.retryWrapper(chainId, async () => { - const code = await this.getCode(channelAddress, chainId); + const code = await this.getCode(channelAddress, chainId, block); if (code.isError) { return Result.fail(code.getError()!); } if (code.getValue() === "0x") { // all balance at channel address *must* be for bob - return this.getOnchainBalance(assetId, channelAddress, chainId); + return this.getOnchainBalance(assetId, channelAddress, chainId, block); } const channelContract = new Contract(channelAddress, ChannelMastercopy.abi, provider); try { - const totalDepositsBob = await channelContract.getTotalDepositsBob(assetId); + const totalDepositsBob = await channelContract.getTotalDepositsBob(assetId, { + blockTag: block.getValue(), + }); return Result.ok(totalDepositsBob); } catch (e) { return Result.fail(e); @@ -328,11 +386,16 @@ export class EthereumChainReader implements IVectorChainReader { transferRegistryAddress: string, chainId: number, bytecode?: string, + blockTag?: Result, ): Promise> { const provider = this.chainProviders[chainId]; if (!provider) { return Result.fail(new ChainError(ChainError.reasons.ProviderNotFound)); } + const block = blockTag ?? (await this.getSafeBlockNumber(chainId)); + if (block.isError) { + return Result.fail(block.getError()!); + } return await this.retryWrapper(chainId, async () => { // Get encoding const registryRes = await this.getRegisteredTransferByDefinition( @@ -340,6 +403,7 @@ export class EthereumChainReader implements IVectorChainReader { transferRegistryAddress, chainId, bytecode, + block, ); if (registryRes.isError) { return Result.fail(registryRes.getError()!); @@ -371,7 +435,7 @@ export class EthereumChainReader implements IVectorChainReader { "Calling create onchain", ); try { - const valid = await contract.create(encodedBalance, encodedState); + const valid = await contract.create(encodedBalance, encodedState, { blockTag: block.getValue() }); return Result.ok(valid); } catch (e) { return Result.fail(e); @@ -379,11 +443,20 @@ export class EthereumChainReader implements IVectorChainReader { }); } - async resolve(transfer: FullTransferState, chainId: number, bytecode?: string): Promise> { + async resolve( + transfer: FullTransferState, + chainId: number, + bytecode?: string, + blockTag?: Result, + ): Promise> { const provider = this.chainProviders[chainId]; if (!provider) { return Result.fail(new ChainError(ChainError.reasons.ProviderNotFound)); } + const block = blockTag ?? (await this.getSafeBlockNumber(chainId)); + if (block.isError) { + return Result.fail(block.getError()!); + } return await this.retryWrapper(chainId, async () => { // Try to encode let encodedState: string; @@ -417,7 +490,9 @@ export class EthereumChainReader implements IVectorChainReader { "Calling resolve onchain", ); try { - const ret = await contract.resolve(encodedBalance, encodedState, encodedResolver); + const ret = await contract.resolve(encodedBalance, encodedState, encodedResolver, { + blockTag: block.getValue(), + }); return Result.ok({ to: ret.to, amount: ret.amount.map((a: BigNumber) => a.toString()), @@ -433,15 +508,22 @@ export class EthereumChainReader implements IVectorChainReader { bob: string, channelFactoryAddress: string, chainId: number, + blockTag?: Result, ): Promise> { const provider = this.chainProviders[chainId]; if (!provider) { return Result.fail(new ChainError(ChainError.reasons.ProviderNotFound)); } + const block = blockTag ?? (await this.getSafeBlockNumber(chainId)); + if (block.isError) { + return Result.fail(block.getError()!); + } return await this.retryWrapper(chainId, async () => { const channelFactory = new Contract(channelFactoryAddress, ChannelFactory.abi, provider); try { - const derivedAddress = await channelFactory.getChannelAddress(alice, bob); + const derivedAddress = await channelFactory.getChannelAddress(alice, bob, { + blockTag: block.getValue(), + }); return Result.ok(derivedAddress); } catch (e) { return Result.fail(e); @@ -449,16 +531,25 @@ export class EthereumChainReader implements IVectorChainReader { }); } - async getCode(address: string, chainId: number): Promise> { + async getCode( + address: string, + chainId: number, + blockTag?: Result, + ): Promise> { const provider = this.chainProviders[chainId]; if (!provider) { return Result.fail(new ChainError(ChainError.reasons.ProviderNotFound)); } + const block = blockTag ?? (await this.getSafeBlockNumber(chainId)); + if (block.isError) { + return Result.fail(block.getError()!); + } return await this.retryWrapper(chainId, async () => { try { - const code = await provider.getCode(address); + const code = await provider.getCode(address, block.getValue()); return Result.ok(code); } catch (e) { + console.log("****** failed to get code"); return Result.fail(e); } }); @@ -530,15 +621,20 @@ export class EthereumChainReader implements IVectorChainReader { owner: string, spender: string, chainId: number, + blockTag?: Result, ): Promise> { const provider = this.chainProviders[chainId]; if (!provider) { return Result.fail(new ChainError(ChainError.reasons.ProviderNotFound)); } + const block = blockTag ?? (await this.getSafeBlockNumber(chainId)); + if (block.isError) { + return Result.fail(block.getError()!); + } return await this.retryWrapper(chainId, async () => { const erc20 = new Contract(tokenAddress, ERC20Abi, provider); try { - const res = await erc20.allowance(owner, spender); + const res = await erc20.allowance(owner, spender, { blockTag: block.getValue() }); return Result.ok(res); } catch (e) { return Result.fail(e); @@ -546,17 +642,26 @@ export class EthereumChainReader implements IVectorChainReader { }); } - async getOnchainBalance(assetId: string, balanceOf: string, chainId: number): Promise> { + async getOnchainBalance( + assetId: string, + balanceOf: string, + chainId: number, + blockTag?: Result, + ): Promise> { const provider = this.chainProviders[chainId]; if (!provider) { return Result.fail(new ChainError(ChainError.reasons.ProviderNotFound)); } + const block = blockTag ?? (await this.getSafeBlockNumber(chainId)); + if (block.isError) { + return Result.fail(block.getError()!); + } return await this.retryWrapper(chainId, async () => { try { const onchainBalance = assetId === AddressZero - ? await provider.getBalance(balanceOf) - : await new Contract(assetId, ERC20Abi, provider).balanceOf(balanceOf); + ? await provider.getBalance(balanceOf, block.getValue()) + : await new Contract(assetId, ERC20Abi, provider).balanceOf(balanceOf, { blockTag: block.getValue() }); return Result.ok(onchainBalance); } catch (e) { return Result.fail(e); @@ -564,14 +669,25 @@ export class EthereumChainReader implements IVectorChainReader { }); } - async getDecimals(assetId: string, chainId: number): Promise> { + async getDecimals( + assetId: string, + chainId: number, + blockTag?: Result, + ): Promise> { const provider = this.chainProviders[chainId]; if (!provider) { return Result.fail(new ChainError(ChainError.reasons.ProviderNotFound)); } + const block = blockTag ?? (await this.getSafeBlockNumber(chainId)); + if (block.isError) { + return Result.fail(block.getError()!); + } return await this.retryWrapper(chainId, async () => { try { - const decimals = assetId === AddressZero ? 18 : await new Contract(assetId, ERC20Abi, provider).decimals(); + const decimals = + assetId === AddressZero + ? 18 + : await new Contract(assetId, ERC20Abi, provider).decimals({ blockTag: block.getValue() }); return Result.ok(decimals); } catch (e) { return Result.fail(e); @@ -583,14 +699,19 @@ export class EthereumChainReader implements IVectorChainReader { withdrawData: WithdrawCommitmentJson, channelAddress: string, chainId: number, + blockTag?: Result, ): Promise> { const provider = this.chainProviders[chainId]; if (!provider) { return Result.fail(new ChainError(ChainError.reasons.ProviderNotFound)); } + const block = blockTag ?? (await this.getSafeBlockNumber(chainId)); + if (block.isError) { + return Result.fail(block.getError()!); + } return await this.retryWrapper(chainId, async () => { // check if it was deployed - const code = await this.getCode(channelAddress, chainId); + const code = await this.getCode(channelAddress, chainId, block); if (code.isError) { return Result.fail(code.getError()!); } @@ -601,15 +722,18 @@ export class EthereumChainReader implements IVectorChainReader { } const channel = new Contract(channelAddress, VectorChannel.abi, provider); try { - const record = await channel.getWithdrawalTransactionRecord({ - channelAddress, - assetId: withdrawData.assetId, - recipient: withdrawData.recipient, - amount: withdrawData.amount, - nonce: withdrawData.nonce, - callTo: withdrawData.callTo, - callData: withdrawData.callData, - }); + const record = await channel.getWithdrawalTransactionRecord( + { + channelAddress, + assetId: withdrawData.assetId, + recipient: withdrawData.recipient, + amount: withdrawData.amount, + nonce: withdrawData.nonce, + callTo: withdrawData.callTo, + callData: withdrawData.callData, + }, + { blockTag: block.getValue() }, + ); return Result.ok(record); } catch (e) { return Result.fail(e); @@ -805,11 +929,17 @@ export class EthereumChainReader implements IVectorChainReader { transferRegistry: string, chainId: number, bytecode?: string, + blockTag?: Result, ): Promise> { const provider = this.chainProviders[chainId]; if (!provider) { return Result.fail(new ChainError(ChainError.reasons.ProviderNotFound)); } + const block = blockTag ?? (await this.getSafeBlockNumber(chainId)); + if (block.isError) { + return Result.fail(block.getError()!); + } + // Registry for chain not loaded, load into memory const registry = new Contract(transferRegistry, TransferRegistry.abi, provider); let registered; @@ -825,7 +955,7 @@ export class EthereumChainReader implements IVectorChainReader { } if (!registered) { try { - registered = await registry.getTransferDefinitions(); + registered = await registry.getTransferDefinitions({ blockTag: block.getValue() }); } catch (e) { return Result.fail(new ChainError(e.message, { chainId, transferRegistry })); } @@ -843,6 +973,21 @@ export class EthereumChainReader implements IVectorChainReader { return Result.ok(cleaned); } + private async getSafeBlockNumber(chainId: number): Promise> { + if (TEST_CHAIN_IDS.includes(chainId)) { + return Result.ok("latest"); + } + + // Doesn't have block + const latest = await this.getBlockNumber(chainId); + if (latest.isError) { + return Result.fail(latest.getError()!); + } + const safe = latest.getValue() - getConfirmationsForChain(chainId); + const positiveSafe = safe < 0 ? 0 : safe; + return Result.ok(positiveSafe); + } + private async retryWrapper( chainId: number, targetMethod: () => Promise>, diff --git a/modules/contracts/src.ts/services/ethService.spec.ts b/modules/contracts/src.ts/services/ethService.spec.ts index 7bcc3ca44..393ac3090 100644 --- a/modules/contracts/src.ts/services/ethService.spec.ts +++ b/modules/contracts/src.ts/services/ethService.spec.ts @@ -39,6 +39,7 @@ let getCodeMock: SinonStub; let getOnchainBalanceMock: SinonStub; let waitForConfirmation: SinonStub<[chainId: number, responses: TransactionResponse[]], Promise>; let getGasPrice: SinonStub<[chainId: number], Promise>>; +let provider1: SinonStubbedInstance; let channelState: FullChannelState; @@ -98,6 +99,7 @@ describe("ethService unit test", () => { const _provider = createStubInstance(JsonRpcProvider); _provider.getTransaction.resolves(txResponse); + provider1 = _provider; provider1337 = _provider; provider1338 = _provider; (signer as any).provider = provider1337; @@ -108,6 +110,7 @@ describe("ethService unit test", () => { { 1337: provider1337, 1338: provider1338, + 1: provider1, }, signer, log, @@ -638,12 +641,12 @@ describe("ethService unit test", () => { }); it("should wait for the required amount of confirmations", async () => { - provider1337.getTransactionReceipt.onFirstCall().resolves({ ...txReceipt, confirmations: 0 }); - provider1337.getTransactionReceipt.onSecondCall().resolves({ ...txReceipt, confirmations: 0 }); - provider1337.getTransactionReceipt.onThirdCall().resolves(txReceipt); - const res = await ethService.waitForConfirmation(1337, [txResponse]); + provider1.getTransactionReceipt.onFirstCall().resolves({ ...txReceipt, confirmations: 0 }); + provider1.getTransactionReceipt.onSecondCall().resolves({ ...txReceipt, confirmations: 0 }); + provider1.getTransactionReceipt.onThirdCall().resolves(txReceipt); + const res = await ethService.waitForConfirmation(1, [txResponse]); expect(res).to.deep.eq(txReceipt); - expect(provider1337.getTransactionReceipt.callCount).to.eq(3); + expect(provider1.getTransactionReceipt.callCount).to.eq(3); }); it("should error with a timeout error if it is past the confirmation time", async () => { diff --git a/modules/contracts/src.ts/services/ethService.ts b/modules/contracts/src.ts/services/ethService.ts index dcd66d3a2..7ada50eea 100644 --- a/modules/contracts/src.ts/services/ethService.ts +++ b/modules/contracts/src.ts/services/ethService.ts @@ -23,8 +23,7 @@ import { encodeTransferResolver, encodeTransferState, getRandomBytes32, - generateMerkleTreeData, - hashCoreTransferState, + getMerkleProof, } from "@connext/vector-utils"; import { Signer } from "@ethersproject/abstract-signer"; import { BigNumber } from "@ethersproject/bignumber"; @@ -74,8 +73,9 @@ export const waitForTransaction = async ( }; export class EthereumChainService extends EthereumChainReader implements IVectorChainService { + private nonces: Map = new Map(); private signers: Map = new Map(); - private queue: PriorityQueue = new PriorityQueue({ concurrency: 1 }); + private queues: Map = new Map(); private evts: { [eventName in ChainServiceEvent]: Evt } = { ...this.disputeEvts, [ChainServiceEvents.TRANSACTION_SUBMITTED]: new Evt(), @@ -95,6 +95,7 @@ export class EthereumChainService extends EthereumChainReader implements IVector parseInt(chainId), typeof signer === "string" ? new Wallet(signer, provider) : (signer.connect(provider) as Signer), ); + this.queues.set(parseInt(chainId), new PriorityQueue({ concurrency: 1 })); }); // TODO: Check to see which tx's are still active / unresolved, and resolve them. @@ -195,21 +196,36 @@ export class EthereumChainService extends EthereumChainReader implements IVector txFn: (gasPrice: BigNumber, nonce: number) => Promise, gasPrice: BigNumber, signer: Signer, + chainId: number, nonce?: number, ): Promise> { // Queue up the execution of the transaction. - return await this.queue.add( - async (): Promise> => { - try { - // Send transaction using the passed in callback. - const actualNonce: number = nonce ?? (await signer.getTransactionCount("pending")); - const response: TransactionResponse | undefined = await txFn(gasPrice, actualNonce); - return Result.ok(response); - } catch (e) { - return Result.fail(e); + if (!this.queues.has(chainId)) { + return Result.fail(new ChainError(ChainError.reasons.SignerNotFound)); + } + // Define task to send tx with proper nonce + const task = async (): Promise> => { + try { + // Send transaction using the passed in callback. + const stored = this.nonces.get(chainId); + const nonceToUse: number = nonce ?? stored ?? (await signer.getTransactionCount("pending")); + const response: TransactionResponse | undefined = await txFn(gasPrice, nonceToUse); + // After calling tx fn, set nonce to the greatest of + // stored, pending, or incremented + const pending = await signer.getTransactionCount("pending"); + const incremented = (response?.nonce ?? nonceToUse) + 1; + // Ensure the nonce you store is *always* the greatest of the values + const toCompare = stored ?? 0; + if (toCompare < pending || toCompare < incremented) { + this.nonces.set(chainId, incremented > pending ? incremented : pending); } - }, - ); + return Result.ok(response); + } catch (e) { + return Result.fail(e); + } + }; + const result = await this.queues.get(chainId)!.add(task); + return result; } /// Check to see if any txs were left in an unfinished state. This should only execute on @@ -358,6 +374,7 @@ export class EthereumChainService extends EthereumChainReader implements IVector // in this data with the already-existing store record of the tx. let responses: TransactionResponse[] = []; let nonce: number | undefined; + let nonceExpired: boolean = false; let receipt: TransactionReceipt | undefined; let gasPrice: BigNumber; @@ -389,7 +406,7 @@ export class EthereumChainService extends EthereumChainReader implements IVector { method, methodId, nonce, tryNumber, channelAddress, gasPrice: gasPrice.toString() }, "Attempting to send transaction", ); - const result = await this.sendTx(txFn, gasPrice, signer, nonce); + const result = await this.sendTx(txFn, gasPrice, signer, chainId, nonce); if (!result.isError) { const response = result.getValue(); if (response) { @@ -456,6 +473,7 @@ export class EthereumChainService extends EthereumChainReader implements IVector // Another ethers message that we could potentially be getting back. error.message.includes("There is another transaction with same nonce in the queue.")) ) { + nonceExpired = true; this.log.info( { method, methodId, channelAddress, reason, nonce, error: error.message }, "Nonce already used: proceeding to check for confirmation in previous transactions.", @@ -479,6 +497,23 @@ export class EthereumChainService extends EthereumChainReader implements IVector } catch (e) { // Check if the error was a confirmation timeout. if (e.message === ChainError.reasons.ConfirmationTimeout) { + if (nonceExpired) { + const error = new ChainError(ChainError.reasons.NonceExpired, { + methodId, + method, + }); + await this.handleTxFail( + onchainTransactionId, + method, + methodId, + channelAddress, + reason, + receipt, + error, + "Nonce expired and could not confirm tx", + ); + return Result.fail(error); + } // Scale up gas by percentage as specified by GAS_BUMP_PERCENT. // From ethers docs: // Generally, the new gas price should be about 50% + 1 wei more, so if a gas price @@ -1329,7 +1364,7 @@ export class EthereumChainService extends EthereumChainReader implements IVector } // Generate merkle root - const { tree } = generateMerkleTreeData(activeTransfers); + const proof = getMerkleProof(activeTransfers, transferIdToDispute); const res = await this.sendTxWithRetries( transferState.channelAddress, @@ -1337,7 +1372,7 @@ export class EthereumChainService extends EthereumChainReader implements IVector TransactionReason.disputeTransfer, (gasPrice, nonce) => { const channel = new Contract(transferState.channelAddress, VectorChannel.abi, signer); - return channel.disputeTransfer(transferState, tree.getHexProof(hashCoreTransferState(transferState)), { + return channel.disputeTransfer(transferState, proof, { gasPrice, nonce, }); diff --git a/modules/contracts/src.ts/tests/cmcs/adjudicator.spec.ts b/modules/contracts/src.ts/tests/cmcs/adjudicator.spec.ts index e991ef48f..4bb191fd8 100644 --- a/modules/contracts/src.ts/tests/cmcs/adjudicator.spec.ts +++ b/modules/contracts/src.ts/tests/cmcs/adjudicator.spec.ts @@ -1,6 +1,6 @@ import { FullChannelState, FullTransferState, HashlockTransferStateEncoding } from "@connext/vector-types"; import { - generateMerkleTreeData, + generateMerkleRoot, ChannelSigner, createlockHash, createTestChannelStateWithSigners, @@ -15,6 +15,7 @@ import { hashCoreTransferState, hashTransferState, signChannelMessage, + getMerkleProof, } from "@connext/vector-utils"; import { BigNumber, BigNumberish } from "@ethersproject/bignumber"; import { AddressZero, HashZero, Zero } from "@ethersproject/constants"; @@ -69,7 +70,7 @@ describe("CMCAdjudicator.sol", async function () { const verifyTransferDispute = async (cts: FullTransferState, disputeBlockNumber: number) => { const { timestamp } = await provider.getBlock(disputeBlockNumber); const transferDispute = await channel.getTransferDispute(cts.transferId); - expect(transferDispute.transferStateHash).to.be.eq(`0x` + hashCoreTransferState(cts).toString("hex")); + expect(transferDispute.transferStateHash).to.be.eq("0x" + hashCoreTransferState(cts).toString("hex")); expect(transferDispute.isDefunded).to.be.false; expect(transferDispute.transferDisputeExpiry).to.be.eq(BigNumber.from(timestamp).add(cts.transferTimeout)); }; @@ -115,14 +116,16 @@ describe("CMCAdjudicator.sol", async function () { }; // Get merkle proof of transfer - const getMerkleProof = (cts: FullTransferState[] = [transferState], toProve: string = transferState.transferId) => { - const { tree } = generateMerkleTreeData(cts); - return tree.getHexProof(hashCoreTransferState(cts.find((t) => t.transferId === toProve)!)); + const getMerkleProofTest = ( + cts: FullTransferState[] = [transferState], + toProve: string = transferState.transferId, + ) => { + return getMerkleProof(cts, toProve); }; // Helper to dispute transfers + bring to defund phase const disputeTransfer = async (cts: FullTransferState = transferState) => { - await (await channel.disputeTransfer(cts, getMerkleProof([cts], cts.transferId))).wait(); + await (await channel.disputeTransfer(cts, getMerkleProofTest([cts], cts.transferId))).wait(); }; // Helper to defund channels and verify transfers @@ -220,7 +223,7 @@ describe("CMCAdjudicator.sol", async function () { transferTimeout: "3", initialStateHash: hashTransferState(state, HashlockTransferStateEncoding), }); - const { root } = generateMerkleTreeData([transferState]); + const root = generateMerkleRoot([transferState]); channelState = createTestChannelStateWithSigners([aliceSigner, bobSigner], "create", { channelAddress: channel.address, assetIds: [AddressZero], @@ -536,7 +539,7 @@ describe("CMCAdjudicator.sol", async function () { } await disputeChannel(); await expect( - channel.disputeTransfer({ ...transferState, channelAddress: getRandomAddress() }, getMerkleProof()), + channel.disputeTransfer({ ...transferState, channelAddress: getRandomAddress() }, getMerkleProofTest()), ).revertedWith("CMCAdjudicator: INVALID_TRANSFER"); }); @@ -546,7 +549,7 @@ describe("CMCAdjudicator.sol", async function () { } await disputeChannel(); await expect( - channel.disputeTransfer({ ...transferState, transferId: getRandomBytes32() }, getMerkleProof()), + channel.disputeTransfer({ ...transferState, transferId: getRandomBytes32() }, getMerkleProofTest()), ).revertedWith("CMCAdjudicator: INVALID_MERKLE_PROOF"); }); @@ -558,7 +561,7 @@ describe("CMCAdjudicator.sol", async function () { // the defund phase const tx = await channel.disputeChannel(channelState, aliceSignature, bobSignature); await tx.wait(); - await expect(channel.disputeTransfer(transferState, getMerkleProof())).revertedWith( + await expect(channel.disputeTransfer(transferState, getMerkleProofTest())).revertedWith( "CMCAdjudicator: INVALID_PHASE", ); }); @@ -569,9 +572,9 @@ describe("CMCAdjudicator.sol", async function () { } const longerTimeout = { ...channelState, timeout: "4" }; await disputeChannel(longerTimeout); - const tx = await channel.disputeTransfer(transferState, getMerkleProof()); + const tx = await channel.disputeTransfer(transferState, getMerkleProofTest()); await tx.wait(); - await expect(channel.disputeTransfer(transferState, getMerkleProof())).revertedWith( + await expect(channel.disputeTransfer(transferState, getMerkleProofTest())).revertedWith( "CMCAdjudicator: TRANSFER_ALREADY_DISPUTED", ); }); @@ -581,7 +584,7 @@ describe("CMCAdjudicator.sol", async function () { this.skip(); } await disputeChannel(); - const tx = await channel.disputeTransfer(transferState, getMerkleProof()); + const tx = await channel.disputeTransfer(transferState, getMerkleProofTest()); const { blockNumber } = await tx.wait(); await verifyTransferDispute(transferState, blockNumber); }); @@ -597,14 +600,14 @@ describe("CMCAdjudicator.sol", async function () { { ...transferState, transferId: getRandomBytes32() }, { ...transferState, transferId: getRandomBytes32() }, ]; - const { root, tree } = generateMerkleTreeData(transfers); + const root = generateMerkleRoot(transfers); const newState = { ...channelState, merkleRoot: root }; await disputeChannel(newState); const txs = []; for (const t of transfers) { - const tx = await channel.disputeTransfer(t, tree.getHexProof(hashCoreTransferState(t))); + const tx = await channel.disputeTransfer(t, getMerkleProof(transfers, t.transferId)); txs.push(tx); } const receipts = await Promise.all(txs.map((tx) => tx.wait())); diff --git a/modules/contracts/src.ts/tests/integration/ethService.spec.ts b/modules/contracts/src.ts/tests/integration/ethService.spec.ts index edf9eb6fd..7b49aa2a1 100644 --- a/modules/contracts/src.ts/tests/integration/ethService.spec.ts +++ b/modules/contracts/src.ts/tests/integration/ethService.spec.ts @@ -10,7 +10,7 @@ import { getRandomBytes32, hashTransferState, MemoryStoreService, - generateMerkleTreeData, + generateMerkleRoot, } from "@connext/vector-utils"; import { AddressZero } from "@ethersproject/constants"; import { Contract } from "@ethersproject/contracts"; @@ -71,7 +71,7 @@ describe("ethService integration", function () { initialStateHash: hashTransferState(state, HashlockTransferStateEncoding), }); - const { root } = generateMerkleTreeData([transferState]); + const root = generateMerkleRoot([transferState]); channelState = createTestChannelStateWithSigners([aliceSigner, bobSigner], "create", { channelAddress: channel.address, assetIds: [AddressZero], diff --git a/modules/documentation/docs/changelog.md b/modules/documentation/docs/changelog.md index 1b000eadb..8f011d7d4 100644 --- a/modules/documentation/docs/changelog.md +++ b/modules/documentation/docs/changelog.md @@ -2,6 +2,21 @@ ## Next Release +## 0.3.0-beta.2 + +- \[contracts\] Use confirmed block for ethReader + +## 0.3.0-beta.1 + +- \[contracts\] Add internal nonce tracking `ethService` +- \[contracts\] Add per-chain queues +- \[contracts\] Add a flag for nonce expired in chain service +- \[protocol\] Remove channel lock +- \[protocol\] Add protocol versioning +- \[messaging\] Remove lock messages +- \[server-node\] Remove `LockService` + + ## 0.2.5-beta.18 - \[node\] Save transaction hash to commitment properly diff --git a/modules/engine/package.json b/modules/engine/package.json index 865243cc2..7bfafe682 100644 --- a/modules/engine/package.json +++ b/modules/engine/package.json @@ -1,6 +1,6 @@ { "name": "@connext/vector-engine", - "version": "0.2.5-beta.18", + "version": "0.3.0-beta.2", "description": "", "author": "Arjun Bhuptani", "license": "MIT", @@ -14,10 +14,10 @@ "test": "nyc ts-mocha --check-leaks --exit --timeout 60000 'src/**/*.spec.ts'" }, "dependencies": { - "@connext/vector-contracts": "0.2.5-beta.18", - "@connext/vector-protocol": "0.2.5-beta.18", - "@connext/vector-types": "0.2.5-beta.18", - "@connext/vector-utils": "0.2.5-beta.18", + "@connext/vector-contracts": "0.3.0-beta.2", + "@connext/vector-protocol": "0.3.0-beta.2", + "@connext/vector-types": "0.3.0-beta.2", + "@connext/vector-utils": "0.3.0-beta.2", "@ethersproject/address": "5.2.0", "@ethersproject/bignumber": "5.2.0", "@ethersproject/bytes": "5.2.0", diff --git a/modules/engine/src/errors.ts b/modules/engine/src/errors.ts index d635865ac..da8a3754f 100644 --- a/modules/engine/src/errors.ts +++ b/modules/engine/src/errors.ts @@ -46,36 +46,6 @@ export class CheckInError extends EngineError { } } -export class RestoreError extends EngineError { - static readonly type = "RestoreError"; - - static readonly reasons = { - AckFailed: "Could not send restore ack", - AcquireLockError: "Failed to acquire restore lock", - ChannelNotFound: "Channel not found", - CouldNotGetActiveTransfers: "Failed to retrieve active transfers from store", - CouldNotGetChannel: "Failed to retrieve channel from store", - GetChannelAddressFailed: "Failed to calculate channel address for verification", - InvalidChannelAddress: "Failed to verify channel address", - InvalidMerkleRoot: "Failed to validate merkleRoot for restoration", - InvalidSignatures: "Failed to validate sigs on latestUpdate", - NoData: "No data sent from counterparty to restore", - ReceivedError: "Got restore error from counterparty", - ReleaseLockError: "Failed to release restore lock", - SaveChannelFailed: "Failed to save channel state", - SyncableState: "Cannot restore, state is syncable. Try reconcileDeposit", - } as const; - - constructor( - public readonly message: Values, - channelAddress: string, - publicIdentifier: string, - context: any = {}, - ) { - super(message, channelAddress, publicIdentifier, context, RestoreError.type); - } -} - export class IsAliveError extends EngineError { static readonly type = "IsAliveError"; @@ -170,3 +140,21 @@ export class WithdrawQuoteError extends EngineError { super(message, request.channelAddress, publicIdentifier, context, WithdrawQuoteError.type); } } +export class AuctionError extends EngineError { + static readonly type = "AuctionError"; + + static readonly reasons = { + NoResponses: "No responses from routers", + ExchangeRateError: "Calculating exchange failed", + SignatureFailure: "Signing quote failed", + } as const; + + constructor( + public readonly message: Values, + publicIdentifier: string, + request: EngineParams.RunAuction, + context: any = {}, + ) { + super(message, request.amount, publicIdentifier, context, AuctionError.type); + } +} diff --git a/modules/engine/src/index.ts b/modules/engine/src/index.ts index 1451fa723..bdc561864 100644 --- a/modules/engine/src/index.ts +++ b/modules/engine/src/index.ts @@ -1,8 +1,8 @@ +import { WithdrawCommitment } from "@connext/vector-contracts"; import { Vector } from "@connext/vector-protocol"; import { ChainAddresses, IChannelSigner, - ILockService, IMessagingService, IVectorProtocol, Result, @@ -19,32 +19,25 @@ import { ChannelRpcMethods, IExternalValidation, AUTODEPLOY_CHAIN_IDS, - FullChannelState, EngineError, - UpdateType, - Values, VectorError, jsonifyError, + RunAuctionPayload, MinimalTransaction, WITHDRAWAL_RESOLVED_EVENT, VectorErrorJson, + getConfirmationsForChain, + NodeResponses, } from "@connext/vector-types"; -import { - generateMerkleTreeData, - validateChannelUpdateSignatures, - getSignerAddressFromPublicIdentifier, - getRandomBytes32, - getParticipant, - hashWithdrawalQuote, - delay, -} from "@connext/vector-utils"; +import { getRandomBytes32, getParticipant, hashWithdrawalQuote, delay } from "@connext/vector-utils"; import pino from "pino"; import Ajv from "ajv"; import { Evt } from "evt"; import { version } from "../package.json"; +import { v4 } from "uuid"; -import { DisputeError, IsAliveError, RestoreError, RpcError } from "./errors"; +import { AuctionError, DisputeError, IsAliveError, RpcError } from "./errors"; import { convertConditionalTransferParams, convertResolveConditionParams, @@ -54,7 +47,6 @@ import { import { setupEngineListeners } from "./listeners"; import { getEngineEvtContainer, withdrawRetryForTransferId, addTransactionToCommitment } from "./utils"; import { sendIsAlive } from "./isAlive"; -import { WithdrawCommitment } from "@connext/vector-contracts"; export const ajv = new Ajv(); @@ -64,8 +56,6 @@ export class VectorEngine implements IVectorEngine { // Setup event container to emit events from vector private readonly evts: EngineEvtContainer = getEngineEvtContainer(); - private readonly restoreLocks: { [channelAddress: string]: string } = {}; - private constructor( private readonly signer: IChannelSigner, private readonly messaging: IMessagingService, @@ -73,13 +63,11 @@ export class VectorEngine implements IVectorEngine { private readonly vector: IVectorProtocol, private readonly chainService: IVectorChainService, private readonly chainAddresses: ChainAddresses, - private readonly lockService: ILockService, private readonly logger: pino.BaseLogger, ) {} static async connect( messaging: IMessagingService, - lock: ILockService, store: IEngineStore, signer: IChannelSigner, chainService: IVectorChainService, @@ -91,7 +79,6 @@ export class VectorEngine implements IVectorEngine { ): Promise { const vector = await Vector.connect( messaging, - lock, store, signer, chainService, @@ -106,7 +93,6 @@ export class VectorEngine implements IVectorEngine { vector, chainService, chainAddresses, - lock, logger.child({ module: "VectorEngine" }), ); await engine.setupListener(gasSubsidyPercentage); @@ -139,59 +125,10 @@ export class VectorEngine implements IVectorEngine { this.chainAddresses, this.logger, this.setup.bind(this), - this.acquireRestoreLocks.bind(this), - this.releaseRestoreLocks.bind(this), gasSubsidyPercentage, ); } - private async acquireRestoreLocks(channel: FullChannelState): Promise> { - if (this.restoreLocks[channel.channelAddress]) { - // Has already been released, return undefined - return Result.ok(this.restoreLocks[channel.channelAddress]); - } - try { - const isAlice = channel.alice === this.signer.address; - const lockVal = await this.lockService.acquireLock( - channel.channelAddress, - isAlice, - isAlice ? channel.bobIdentifier : channel.aliceIdentifier, - ); - this.restoreLocks[channel.channelAddress] = lockVal; - return Result.ok(undefined); - } catch (e) { - return Result.fail( - new RestoreError(RestoreError.reasons.AcquireLockError, channel.channelAddress, this.signer.publicIdentifier, { - acquireRestoreLockError: e.message, - }), - ); - } - } - - private async releaseRestoreLocks(channel: FullChannelState): Promise> { - if (!this.restoreLocks[channel.channelAddress]) { - // Has already been released, return undefined - return Result.ok(undefined); - } - try { - const isAlice = channel.alice === this.signer.address; - await this.lockService.releaseLock( - channel.channelAddress, - this.restoreLocks[channel.channelAddress], - isAlice, - isAlice ? channel.bobIdentifier : channel.aliceIdentifier, - ); - delete this.restoreLocks[channel.channelAddress]; - return Result.ok(undefined); - } catch (e) { - return Result.fail( - new RestoreError(RestoreError.reasons.ReleaseLockError, channel.channelAddress, this.signer.publicIdentifier, { - releaseRestoreLockError: e.message, - }), - ); - } - } - private async getConfig(): Promise< Result > { @@ -712,7 +649,6 @@ export class VectorEngine implements IVectorEngine { params: EngineParams.Deposit, ): Promise> { const method = "deposit"; - const timeout = 500; const methodId = getRandomBytes32(); this.logger.info({ params, method, methodId }, "Method started"); const validate = ajv.compile(EngineParams.DepositSchema); @@ -735,8 +671,8 @@ export class VectorEngine implements IVectorEngine { // own. Bob reconciles 8 and fails to recover Alice's signature properly // leaving all 8 out of the channel. - // There is no way to eliminate this race condition, so instead just retry - // depositing if a signature validation error is detected. + // This race condition should be handled by the protocol retries + const timeout = 500; let depositRes = await this.vector.deposit(params); let count = 1; for (const _ of Array(3).fill(0)) { @@ -1078,7 +1014,9 @@ export class VectorEngine implements IVectorEngine { private async addTransactionToCommitment( params: EngineParams.AddTransactionToCommitment, - ): Promise> { + ): Promise< + Result + > { const method = "addTransactionToCommitment"; const methodId = getRandomBytes32(); this.logger.info({ params, method, methodId }, "Method started"); @@ -1235,7 +1173,11 @@ export class VectorEngine implements IVectorEngine { } // RESTORE STATE - // NOTE: MUST be under protocol lock + // NOTE: this is not added to the protocol queue. That is because if your + // channel needs to be restored, any updates you are sent or try to send + // will fail until your store is properly updated. The failures create + // a natural lock. However, it is due to these failures that the protocol + // methods are retried. private async restoreState( params: EngineParams.RestoreState, ): Promise> { @@ -1253,146 +1195,31 @@ export class VectorEngine implements IVectorEngine { ); } - // Send message to counterparty, they will grab lock and - // return information under lock, initiator will update channel, - // then send confirmation message to counterparty, who will release the lock - const { chainId, counterpartyIdentifier } = params; - const restoreDataRes = await this.messaging.sendRestoreStateMessage( - Result.ok({ chainId }), - counterpartyIdentifier, - this.signer.publicIdentifier, - ); - if (restoreDataRes.isError) { - return Result.fail(restoreDataRes.getError()!); + // Request protocol restore + const restoreResult = await this.vector.restoreState(params); + if (restoreResult.isError) { + return Result.fail(restoreResult.getError()!); } - const { channel, activeTransfers } = restoreDataRes.getValue() ?? ({} as any); - - // Here you are under lock, verify things about channel - // Create helper to send message allowing a release lock - const sendResponseToCounterparty = async (error?: Values, context: any = {}) => { - if (!error) { - const res = await this.messaging.sendRestoreStateMessage( - Result.ok({ - channelAddress: channel.channelAddress, - }), - counterpartyIdentifier, - this.signer.publicIdentifier, - ); - if (res.isError) { - error = RestoreError.reasons.AckFailed; - context = { error: jsonifyError(res.getError()!) }; - } else { - return Result.ok(channel); - } - } - - // handle error by returning it to counterparty && returning result - const err = new RestoreError(error, channel?.channelAddress ?? "", this.publicIdentifier, { - ...context, - method, - params, - }); - await this.messaging.sendRestoreStateMessage( - Result.fail(err), - counterpartyIdentifier, - this.signer.publicIdentifier, - ); - return Result.fail(err); - }; - - // Verify data exists - if (!channel || !activeTransfers) { - return sendResponseToCounterparty(RestoreError.reasons.NoData); - } - - // Verify channel address is same as calculated - const counterparty = getSignerAddressFromPublicIdentifier(counterpartyIdentifier); - const calculated = await this.chainService.getChannelAddress( - channel.alice === this.signer.address ? this.signer.address : counterparty, - channel.bob === this.signer.address ? this.signer.address : counterparty, - channel.networkContext.channelFactoryAddress, - chainId, - ); - if (calculated.isError) { - return sendResponseToCounterparty(RestoreError.reasons.GetChannelAddressFailed, { - getChannelAddressError: jsonifyError(calculated.getError()!), - }); - } - if (calculated.getValue() !== channel.channelAddress) { - return sendResponseToCounterparty(RestoreError.reasons.InvalidChannelAddress, { - calculated: calculated.getValue(), - }); - } - - // Verify signatures on latest update - const sigRes = await validateChannelUpdateSignatures( - channel, - channel.latestUpdate.aliceSignature, - channel.latestUpdate.bobSignature, - "both", - ); - if (sigRes.isError) { - return sendResponseToCounterparty(RestoreError.reasons.InvalidSignatures, { - recoveryError: sigRes.getError().message, - }); - } - - // Verify transfers match merkleRoot - const { root } = generateMerkleTreeData(activeTransfers); - if (root !== channel.merkleRoot) { - return sendResponseToCounterparty(RestoreError.reasons.InvalidMerkleRoot, { - calculated: root, - merkleRoot: channel.merkleRoot, - activeTransfers: activeTransfers.map((t) => t.transferId), - }); - } - - // Verify nothing with a sync-able nonce exists in store - const existing = await this.getChannelState({ channelAddress: channel.channelAddress }); - if (existing.isError) { - return sendResponseToCounterparty(RestoreError.reasons.CouldNotGetChannel, { - getChannelStateError: jsonifyError(existing.getError()!), - }); - } - const nonce = existing.getValue()?.nonce ?? 0; - const diff = channel.nonce - nonce; - if (diff <= 1 && channel.latestUpdate.type !== UpdateType.setup) { - return sendResponseToCounterparty(RestoreError.reasons.SyncableState, { - existing: nonce, - toRestore: channel.nonce, - }); - } - - // Save channel - try { - await this.store.saveChannelStateAndTransfers(channel, activeTransfers); - } catch (e) { - return sendResponseToCounterparty(RestoreError.reasons.SaveChannelFailed, { - saveChannelStateAndTransfersError: e.message, - }); - } - - // Respond by saying this was a success - const returnVal = await sendResponseToCounterparty(); + const channel = restoreResult.getValue(); // Post to evt this.evts[EngineEvents.RESTORE_STATE_EVENT].post({ channelAddress: channel.channelAddress, aliceIdentifier: channel.aliceIdentifier, bobIdentifier: channel.bobIdentifier, - chainId, + chainId: channel.networkContext.chainId, }); this.logger.info( { - result: returnVal.isError ? jsonifyError(returnVal.getError()!) : returnVal.getValue(), + channel: channel.channelAddress, method, methodId, }, "Method complete", ); - return returnVal; + return Result.ok(channel); } // DISPUTE METHODS @@ -1648,6 +1475,8 @@ export class VectorEngine implements IVectorEngine { return Result.ok(results); } + // NOTE: no need to retry here because this method is not relevant + // to restoreState conditions private async syncDisputes(): Promise> { try { await this.vector.syncDisputes(); @@ -1662,6 +1491,91 @@ export class VectorEngine implements IVectorEngine { } } + private async runAuction( + params: EngineParams.RunAuction, + ): Promise> { + const validate = ajv.compile(EngineParams.RunAuctionSchema); + const valid = validate(params); + if (!valid) { + return Result.fail( + new RpcError(RpcError.reasons.InvalidParams, "", this.publicIdentifier, { + invalidParamsError: validate.errors?.map((e) => e.message).join(","), + invalidParams: params, + }), + ); + } + + const payload: RunAuctionPayload = { + amount: params.amount, + senderPublicIdentifier: this.publicIdentifier, + senderAssetId: params.assetId, + senderChainId: params.chainId, + receiverPublicIdentifier: params.recipient, + receiverAssetId: params.recipientAssetId, + receiverChainId: params.recipientChainId, + }; + this.evts[EngineEvents.RUN_AUCTION_EVENT].post(payload); + + const inbox = v4(); + const from = this.signer.publicIdentifier; + let auctionResponses: Array = []; + + // Call onReceiveAuctionMessage to listen on unique INBOX and collect responses for 5 seconds (will tweak and tune this number). + // Maybe something like wait for 5 responses or 5 seconds? Watch out for race conditions of setting listener after message is already sent. + try { + //Call publishStartAuction with provided data. + this.messaging.publishStartAuction(from, from, Result.ok(params), inbox); + + await this.messaging.onReceiveAuctionMessage(this.publicIdentifier, inbox, (runAuction, from, inbox) => { + const method = "onReceiveAuctionMessage"; + const methodId = getRandomBytes32(); + + if (runAuction.isError) { + this.logger.error({ error: runAuction.getError()?.message, method, methodId }, "Error received"); + return; + } + const res = runAuction.getValue(); + auctionResponses.push(res); + }); + + // wait for 5 responses or 3 secs + if (auctionResponses.length < 5) { + await delay(3000); + } + + this.logger.info(auctionResponses, "Router Responses"); + + if (auctionResponses.length === 0) { + // TODO: Add error cases (reasons) to Error Class + return Result.fail(new AuctionError(AuctionError.reasons.NoResponses, this.publicIdentifier, params, {})); + } + + // compare fees and return cheapest option + // TODO: compare swapRates also + let lowestFee = parseInt(auctionResponses[0].totalFee); + let lowestFeeIndex = 0; + + for (const [i, elem] of auctionResponses.entries()) { + if (parseInt(elem.totalFee) < lowestFee) { + lowestFee = parseInt(elem.totalFee); + lowestFeeIndex = i; + } + } + this.logger.info(auctionResponses[lowestFeeIndex], "Chosen Router"); + return Result.ok(auctionResponses[lowestFeeIndex]); + } catch (err) { + return Result.fail( + new RpcError(RpcError.reasons.InvalidParams, "", this.publicIdentifier, { + invalidParamsError: validate.errors?.map((e) => e.message).join(","), + invalidParams: params, + }), + ); + } finally { + auctionResponses = []; + this.messaging.unsubscribe(inbox); + } + } + // JSON RPC interface -- this will accept: // - "chan_deposit" // - "chan_createTransfer" diff --git a/modules/engine/src/listeners.ts b/modules/engine/src/listeners.ts index ae6ac20d1..502af2bd9 100644 --- a/modules/engine/src/listeners.ts +++ b/modules/engine/src/listeners.ts @@ -44,7 +44,7 @@ import { BigNumber } from "@ethersproject/bignumber"; import { Zero } from "@ethersproject/constants"; import Pino, { BaseLogger } from "pino"; -import { IsAliveError, RestoreError, WithdrawQuoteError } from "./errors"; +import { IsAliveError, WithdrawQuoteError } from "./errors"; import { EngineEvtContainer } from "./index"; import { submitUnsubmittedWithdrawals } from "./utils"; @@ -60,8 +60,6 @@ export async function setupEngineListeners( setup: ( params: EngineParams.Setup, ) => Promise>, - acquireRestoreLocks: (channel: FullChannelState) => Promise>, - releaseRestoreLocks: (channel: FullChannelState) => Promise>, gasSubsidyPercentage: number, ): Promise { // Set up listener for channel setup @@ -171,124 +169,6 @@ export async function setupEngineListeners( }, ); - await messaging.onReceiveRestoreStateMessage( - signer.publicIdentifier, - async ( - restoreData: Result<{ chainId: number } | { channelAddress: string }, EngineError>, - from: string, - inbox: string, - ) => { - // If it is from yourself, do nothing - if (from === signer.publicIdentifier) { - return; - } - const method = "onReceiveRestoreStateMessage"; - logger.debug({ method }, "Handling message"); - - // releases the lock, and acks to senders confirmation message - const releaseLockAndAck = async (channelAddress: string, postToEvt = false) => { - const channel = await store.getChannelState(channelAddress); - if (!channel) { - logger.error({ channelAddress }, "Failed to find channel to release lock"); - return; - } - await releaseRestoreLocks(channel); - await messaging.respondToRestoreStateMessage(inbox, Result.ok(undefined)); - if (postToEvt) { - // Post to evt - evts[EngineEvents.RESTORE_STATE_EVENT].post({ - channelAddress: channel.channelAddress, - aliceIdentifier: channel.aliceIdentifier, - bobIdentifier: channel.bobIdentifier, - chainId: channel.networkContext.chainId, - }); - } - return; - }; - - // Received error from counterparty - if (restoreData.isError) { - // releasing the lock should be done regardless of error - logger.error({ message: restoreData.getError()!.message, method }, "Error received from counterparty restore"); - await releaseLockAndAck(restoreData.getError()!.context.channelAddress); - return; - } - - const data = restoreData.getValue(); - const [key] = Object.keys(data ?? []); - if (key !== "chainId" && key !== "channelAddress") { - logger.error({ data }, "Message malformed"); - return; - } - - if (key === "channelAddress") { - const { channelAddress } = data as { channelAddress: string }; - await releaseLockAndAck(channelAddress, true); - return; - } - - // Otherwise, they are looking to initiate a sync - let channel: FullChannelState | undefined; - const sendCannotRestoreFromError = (error: Values, context: any = {}) => { - return messaging.respondToRestoreStateMessage( - inbox, - Result.fail( - new RestoreError(error, channel?.channelAddress ?? "", signer.publicIdentifier, { ...context, method }), - ), - ); - }; - - // Get info from store to send to counterparty - const { chainId } = data as any; - try { - channel = await store.getChannelStateByParticipants(signer.publicIdentifier, from, chainId); - } catch (e) { - return sendCannotRestoreFromError(RestoreError.reasons.CouldNotGetChannel, { - storeMethod: "getChannelStateByParticipants", - chainId, - identifiers: [signer.publicIdentifier, from], - }); - } - if (!channel) { - return sendCannotRestoreFromError(RestoreError.reasons.ChannelNotFound, { chainId }); - } - let activeTransfers: FullTransferState[]; - try { - activeTransfers = await store.getActiveTransfers(channel.channelAddress); - } catch (e) { - return sendCannotRestoreFromError(RestoreError.reasons.CouldNotGetActiveTransfers, { - storeMethod: "getActiveTransfers", - chainId, - channelAddress: channel.channelAddress, - }); - } - - // Acquire lock - const res = await acquireRestoreLocks(channel); - if (res.isError) { - return sendCannotRestoreFromError(RestoreError.reasons.AcquireLockError, { - acquireLockError: jsonifyError(res.getError()!), - }); - } - - // Send info to counterparty - logger.debug( - { - channel: channel.channelAddress, - nonce: channel.nonce, - activeTransfers: activeTransfers.map((a) => a.transferId), - }, - "Sending counterparty state to sync", - ); - await messaging.respondToRestoreStateMessage(inbox, Result.ok({ channel, activeTransfers })); - - // Release lock on timeout regardless - setTimeout(() => { - releaseRestoreLocks(channel!); - }, 15_000); - }, - ); - await messaging.onReceiveIsAliveMessage( signer.publicIdentifier, async ( diff --git a/modules/engine/src/testing/index.spec.ts b/modules/engine/src/testing/index.spec.ts index ddc741dcb..762f9f5d9 100644 --- a/modules/engine/src/testing/index.spec.ts +++ b/modules/engine/src/testing/index.spec.ts @@ -6,7 +6,6 @@ import { getTestLoggers, MemoryStoreService, MemoryMessagingService, - MemoryLockService, getRandomBytes32, mkPublicIdentifier, mkAddress, @@ -51,7 +50,6 @@ describe("VectorEngine", () => { it("should connect without validation", async () => { const engine = await VectorEngine.connect( Sinon.createStubInstance(MemoryMessagingService), - Sinon.createStubInstance(MemoryLockService), storeService, getRandomChannelSigner(), chainService as IVectorChainService, @@ -66,7 +64,6 @@ describe("VectorEngine", () => { it("should connect with validation", async () => { const engine = await VectorEngine.connect( Sinon.createStubInstance(MemoryMessagingService), - Sinon.createStubInstance(MemoryLockService), storeService, getRandomChannelSigner(), chainService as IVectorChainService, @@ -156,7 +153,6 @@ describe("VectorEngine", () => { it(test.name, async () => { const engine = await VectorEngine.connect( Sinon.createStubInstance(MemoryMessagingService), - Sinon.createStubInstance(MemoryLockService), storeService, getRandomChannelSigner(), chainService as IVectorChainService, @@ -195,7 +191,6 @@ describe("VectorEngine", () => { it(test.name, async () => { const engine = await VectorEngine.connect( Sinon.createStubInstance(MemoryMessagingService), - Sinon.createStubInstance(MemoryLockService), storeService, getRandomChannelSigner(), chainService as IVectorChainService, @@ -803,13 +798,187 @@ describe("VectorEngine", () => { overrides: { method: "chan_defundTransfer", params: { transferId: invalidAddress } }, error: malformedTransactionId, }, + { + name: "chan_runAuction malformed parameter amount", + overrides: { + method: "chan_runAuction", + params: { + amount: "-1", + assetId: validAddress, + chainId: validAddress, + recipient: counterpartyIdentifier, + recipientAssetId: validAddress, + recipientChainId: validAddress, + }, + }, + error: 'should match pattern "^([0-9])*$"', + }, + { + name: "chan_runAuction missing parameter amount", + overrides: { + method: "chan_runAuction", + params: { + amount: undefined, + assetId: validAddress, + chainId: validAddress, + recipient: counterpartyIdentifier, + recipientAssetId: validAddress, + recipientChainId: validAddress, + }, + }, + error: missingParam("amount"), + }, + { + name: "chan_runAuction missing parameter assetId", + overrides: { + method: "chan_runAuction", + params: { + amount: "1", + chainId: validAddress, + recipient: counterpartyIdentifier, + recipientAssetId: validAddress, + recipientChainId: validAddress, + }, + }, + error: missingParam("assetId"), + }, + { + name: "chan_runAuction malformed parameter assetId", + overrides: { + method: "chan_runAuction", + params: { + amount: "1", + assetId: invalidAddress, + chainId: validAddress, + recipient: counterpartyIdentifier, + recipientAssetId: validAddress, + recipientChainId: validAddress, + }, + }, + error: malformedAddress, + }, + { + name: "chan_runAuction missing parameter chainId", + overrides: { + method: "chan_runAuction", + params: { + amount: "1", + assetId: validAddress, + recipient: counterpartyIdentifier, + recipientAssetId: validAddress, + recipientChainId: validAddress, + }, + }, + error: missingParam("chainId"), + }, + { + name: "chan_runAuction malformed parameter chainId", + overrides: { + method: "chan_runAuction", + params: { + amount: "1", + assetId: validAddress, + chainId: -1, + recipient: counterpartyIdentifier, + recipientAssetId: validAddress, + recipientChainId: validAddress, + }, + }, + error: "should be >= 1", + }, + { + name: "chan_runAuction missing parameter recipient", + overrides: { + method: "chan_runAuction", + params: { + amount: "1", + assetId: validAddress, + chainId: 1, + recipientAssetId: validAddress, + recipientChainId: 1, + }, + }, + error: missingParam("recipient"), + }, + { + name: "chan_runAuction malformed parameter recipient", + overrides: { + method: "chan_runAuction", + params: { + amount: "1", + assetId: validAddress, + chainId: 1, + recipient: invalidAddress, + recipientAssetId: validAddress, + recipientChainId: 1, + }, + }, + error: malformedPublicIdentifier, + }, + { + name: "chan_runAuction missing parameter recipientChainId", + overrides: { + method: "chan_runAuction", + params: { + amount: "1", + assetId: validAddress, + chainId: 1, + recipient: counterpartyIdentifier, + recipientAssetId: validAddress, + }, + }, + error: missingParam("recipientChainId"), + }, + { + name: "chan_runAuction malformed parameter recipientChainId", + overrides: { + method: "chan_runAuction", + params: { + amount: "1", + assetId: validAddress, + chainId: 1, + recipient: counterpartyIdentifier, + recipientChainId: -1, + recipientAssetId: validAddress, + }, + }, + error: "should be >= 1", + }, + { + name: "chan_runAuction missing parameter recipientAssetId", + overrides: { + method: "chan_runAuction", + params: { + amount: "1", + assetId: validAddress, + chainId: 1, + recipient: counterpartyIdentifier, + recipientChainId: 1, + }, + }, + error: missingParam("recipientAssetId"), + }, + { + name: "chan_runAuction malformed parameter recipientAssetId", + overrides: { + method: "chan_runAuction", + params: { + amount: "1", + assetId: validAddress, + chainId: 1, + recipient: counterpartyIdentifier, + recipientChainId: 1, + recipientAssetId: invalidAddress, + }, + }, + error: malformedAddress, + }, ]; for (const test of paramsTests) { it(test.name, async () => { const engine = await VectorEngine.connect( Sinon.createStubInstance(MemoryMessagingService), - Sinon.createStubInstance(MemoryLockService), storeService, getRandomChannelSigner(), chainService as IVectorChainService, diff --git a/modules/engine/src/testing/listeners.spec.ts b/modules/engine/src/testing/listeners.spec.ts index dc2fd1b37..708b55880 100644 --- a/modules/engine/src/testing/listeners.spec.ts +++ b/modules/engine/src/testing/listeners.spec.ts @@ -100,8 +100,6 @@ describe(testName, () => { let store: Sinon.SinonStubbedInstance; let chainService: Sinon.SinonStubbedInstance; let messaging: Sinon.SinonStubbedInstance; - let acquireRestoreLockStub: Sinon.SinonStub; - let releaseRestoreLockStub: Sinon.SinonStub; // Create an EVT to post to, that can be aliased as a // vector instance @@ -131,10 +129,6 @@ describe(testName, () => { vector = Sinon.createStubInstance(Vector); messaging = Sinon.createStubInstance(MemoryMessagingService); vector.on = on as any; - - // By default acquire/release for restore succeeds - acquireRestoreLockStub = Sinon.stub().resolves(Result.ok(undefined)); - releaseRestoreLockStub = Sinon.stub().resolves(Result.ok(undefined)); }); afterEach(() => { @@ -347,8 +341,6 @@ describe(testName, () => { chainAddresses, log, () => Promise.resolve(Result.ok({} as any)), - acquireRestoreLockStub, - releaseRestoreLockStub, gasSubsidyPercentage, ); @@ -464,8 +456,6 @@ describe(testName, () => { chainAddresses, log, () => Promise.resolve(Result.ok({} as any)), - acquireRestoreLockStub, - releaseRestoreLockStub, 50, ); diff --git a/modules/engine/src/testing/utils.spec.ts b/modules/engine/src/testing/utils.spec.ts index 180edd006..a2352c450 100644 --- a/modules/engine/src/testing/utils.spec.ts +++ b/modules/engine/src/testing/utils.spec.ts @@ -59,8 +59,6 @@ describe(testName, () => { let store: Sinon.SinonStubbedInstance; let chainService: Sinon.SinonStubbedInstance; let messaging: Sinon.SinonStubbedInstance; - let acquireRestoreLockStub: Sinon.SinonStub; - let releaseRestoreLockStub: Sinon.SinonStub; let withdrawRetryForTrasferIdStub: Sinon.SinonStub; // Create an EVT to post to, that can be aliased as a @@ -92,9 +90,6 @@ describe(testName, () => { messaging = Sinon.createStubInstance(MemoryMessagingService); vector.on = on as any; - // By default acquire/release for restore succeeds - acquireRestoreLockStub = Sinon.stub().resolves(Result.ok(undefined)); - releaseRestoreLockStub = Sinon.stub().resolves(Result.ok(undefined)); withdrawRetryForTrasferIdStub = Sinon.stub(utils, "withdrawRetryForTransferId"); }); diff --git a/modules/engine/src/utils.ts b/modules/engine/src/utils.ts index b2722facc..395b315aa 100644 --- a/modules/engine/src/utils.ts +++ b/modules/engine/src/utils.ts @@ -21,6 +21,7 @@ import { TransferDisputedPayload, TransferDefundedPayload, ConditionalTransferRoutingCompletePayload, + RunAuctionPayload, ChainAddresses, ChannelRpcMethodsResponsesMap, ChannelRpcMethods, @@ -55,6 +56,7 @@ export const getEngineEvtContainer = (): EngineEvtContainer => { [EngineEvents.WITHDRAWAL_CREATED]: Evt.create(), [EngineEvents.WITHDRAWAL_RESOLVED]: Evt.create(), [EngineEvents.WITHDRAWAL_RECONCILED]: Evt.create(), + [EngineEvents.RUN_AUCTION_EVENT]: Evt.create(), [ChainServiceEvents.TRANSACTION_SUBMITTED]: Evt.create< TransactionSubmittedPayload & { publicIdentifier: string } >(), diff --git a/modules/iframe-app/ops/config-overrides.js b/modules/iframe-app/ops/config-overrides.js new file mode 100644 index 000000000..a7b3b2326 --- /dev/null +++ b/modules/iframe-app/ops/config-overrides.js @@ -0,0 +1,29 @@ +// Goal: add wasm support to a create-react-app +// Solution derived from: https://stackoverflow.com/a/61722010 + +const path = require("path"); + +module.exports = function override(config, env) { + const wasmExtensionRegExp = /\.wasm$/; + + config.resolve.extensions.push(".wasm"); + + // make sure the file-loader ignores WASM files + config.module.rules.forEach((rule) => { + (rule.oneOf || []).forEach((oneOf) => { + if (oneOf.loader && oneOf.loader.indexOf("file-loader") >= 0) { + oneOf.exclude.push(wasmExtensionRegExp); + } + }); + }); + + // add new loader to handle WASM files + config.module.rules.push({ + include: path.resolve(__dirname, "src"), + test: wasmExtensionRegExp, + type: "webassembly/experimental", + use: [{ loader: require.resolve("wasm-loader"), options: {} }], + }); + + return config; +}; diff --git a/modules/iframe-app/package.json b/modules/iframe-app/package.json index 1182a260a..1e7c7b1b9 100644 --- a/modules/iframe-app/package.json +++ b/modules/iframe-app/package.json @@ -3,9 +3,9 @@ "version": "0.0.1", "private": true, "dependencies": { - "@connext/vector-browser-node": "0.2.5-beta.18", - "@connext/vector-types": "0.2.5-beta.18", - "@connext/vector-utils": "0.2.5-beta.18", + "@connext/vector-browser-node": "0.3.0-beta.2", + "@connext/vector-types": "0.3.0-beta.2", + "@connext/vector-utils": "0.3.0-beta.2", "@ethersproject/address": "5.2.0", "@ethersproject/bytes": "5.2.0", "@ethersproject/hdnode": "5.2.0", @@ -22,14 +22,16 @@ "react": "17.0.1", "react-dom": "17.0.1", "react-scripts": "3.4.3", - "typescript": "4.2.4" + "react-app-rewired": "2.1.8", + "typescript": "4.2.4", + "wasm-loader": "1.3.0" }, "scripts": { - "start": "BROWSER=none PORT=3030 react-scripts start", - "build": "REACT_APP_VECTOR_CONFIG=$(cat \"../../ops/config/browser.default.json\") SKIP_PREFLIGHT_CHECK=true react-scripts build", - "build-prod": "SKIP_PREFLIGHT_CHECK=true react-scripts build", - "test": "react-scripts test", - "eject": "react-scripts eject" + "start": "BROWSER=none PORT=3030 react-app-rewired start", + "build": "REACT_APP_VECTOR_CONFIG=$(cat \"../../ops/config/browser.default.json\") SKIP_PREFLIGHT_CHECK=true react-app-rewired --max_old_space_size=4096 build", + "build-prod": "SKIP_PREFLIGHT_CHECK=true react-app-rewired --max_old_space_size=4096 build", + "test": "react-app-rewired test", + "eject": "react-app-rewired eject" }, "eslintConfig": { "extends": [ @@ -58,5 +60,6 @@ "pino-pretty": "4.6.0", "chai": "4.3.1", "sinon": "10.0.0" - } + }, + "config-overrides-path": "ops/config-overrides" } diff --git a/modules/iframe-app/src/App.tsx b/modules/iframe-app/src/App.tsx index aa6a5e45d..05174d26b 100644 --- a/modules/iframe-app/src/App.tsx +++ b/modules/iframe-app/src/App.tsx @@ -1,18 +1,23 @@ -import React from "react"; +import React, { useEffect } from "react"; import ConnextManager from "./ConnextManager"; -// eslint-disable-next-line -const connextManager = new ConnextManager(); +function App() { + const loadWasmLibs = async () => { + const browser = await import("@connext/vector-browser-node"); + const utils = await import("@connext/vector-utils"); + new ConnextManager(browser, utils); + }; -class App extends React.Component { - render() { - return ( -
-
Testing
-
- ); - } + useEffect(() => { + loadWasmLibs(); + }, []); + + return ( +
+
+
+ ); } export default App; diff --git a/modules/iframe-app/src/ConnextManager.tsx b/modules/iframe-app/src/ConnextManager.tsx index 2a38d11c7..1f9ff8686 100644 --- a/modules/iframe-app/src/ConnextManager.tsx +++ b/modules/iframe-app/src/ConnextManager.tsx @@ -1,4 +1,3 @@ -import { BrowserNode, NonEIP712Message } from "@connext/vector-browser-node"; import { ChainAddresses, ChannelRpcMethod, @@ -6,7 +5,6 @@ import { EngineParams, jsonifyError, } from "@connext/vector-types"; -import { ChannelSigner, constructRpcRequest, safeJsonParse } from "@connext/vector-utils"; import { entropyToMnemonic } from "@ethersproject/hdnode"; import { keccak256 } from "@ethersproject/keccak256"; import { toUtf8Bytes } from "@ethersproject/strings"; @@ -20,9 +18,12 @@ import { config } from "./config"; export default class ConnextManager { private parentOrigin: string; - private browserNode: BrowserNode | undefined; + private browserNode: any | undefined; - constructor() { + private utilsPkg: any; + private browserPkg: any; + + constructor(browserPkg: any, utilsPkg: any) { this.parentOrigin = new URL(document.referrer).origin; window.addEventListener("message", (e) => this.handleIncomingMessage(e), true); if (document.readyState === "loading") { @@ -32,6 +33,9 @@ export default class ConnextManager { } else { window.parent.postMessage("event:iframe-initialized", this.parentOrigin); } + + this.utilsPkg = utilsPkg; + this.browserPkg = browserPkg; } private async initNode( @@ -42,7 +46,7 @@ export default class ConnextManager { messagingUrl?: string, natsUrl?: string, authUrl?: string, - ): Promise { + ): Promise { console.log(`initNode params: `, { chainProviders, chainAddresses, @@ -57,7 +61,7 @@ export default class ConnextManager { throw new Error("localStorage not available in this window, please enable cross-site cookies and try again."); } - const recovered = verifyMessage(NonEIP712Message, signature); + const recovered = verifyMessage(this.browserPkg.NonEIP712Message, signature); if (getAddress(recovered) !== getAddress(signerAddress)) { throw new Error( `Signature not properly recovered. expected ${signerAddress}, got ${recovered}, signature: ${signature}`, @@ -84,9 +88,9 @@ export default class ConnextManager { // since the signature depends on the private key stored by Magic/Metamask, this is not forgeable by an adversary const mnemonic = entropyToMnemonic(keccak256(signature)); const privateKey = Wallet.fromMnemonic(mnemonic).privateKey; - const signer = new ChannelSigner(privateKey); + const signer = new this.utilsPkg.ChannelSigner(privateKey); - this.browserNode = await BrowserNode.connect({ + this.browserNode = await this.browserPkg.BrowserNode.connect({ signer, chainAddresses: chainAddresses ?? config.chainAddresses, chainProviders, @@ -96,12 +100,13 @@ export default class ConnextManager { natsUrl: _natsUrl, }); localStorage.setItem("publicIdentifier", signer.publicIdentifier); + return this.browserNode; } private async handleIncomingMessage(e: MessageEvent) { if (e.origin !== this.parentOrigin) return; - const request = safeJsonParse(e.data); + const request = this.utilsPkg.safeJsonParse(e.data); let response: any; try { const result = await this.handleRequest(request); @@ -137,7 +142,7 @@ export default class ConnextManager { if (!signerAddress) { throw new Error("No account available"); } - signature = await signer.signMessage(NonEIP712Message); + signature = await signer.signMessage(this.browserPkg.NonEIP712Message); } if (!signature) { @@ -166,7 +171,7 @@ export default class ConnextManager { if (request.method === "chan_subscribe") { const subscription = keccak256(toUtf8Bytes(`${request.id}`)); const listener = (data: any) => { - const payload = constructRpcRequest<"chan_subscription">("chan_subscription", { + const payload = this.utilsPkg.constructRpcRequest("chan_subscription", { subscription, data, }); diff --git a/modules/protocol/package.json b/modules/protocol/package.json index 454a5341a..4c0cc891b 100644 --- a/modules/protocol/package.json +++ b/modules/protocol/package.json @@ -1,6 +1,6 @@ { "name": "@connext/vector-protocol", - "version": "0.2.5-beta.18", + "version": "0.3.0-beta.2", "description": "", "main": "dist/vector.js", "types": "dist/vector.d.ts", @@ -14,9 +14,10 @@ "author": "Arjun Bhuptani", "license": "MIT", "dependencies": { - "@connext/vector-contracts": "0.2.5-beta.18", - "@connext/vector-types": "0.2.5-beta.18", - "@connext/vector-utils": "0.2.5-beta.18", + "@connext/vector-merkle-tree": "0.1.4", + "@connext/vector-contracts": "0.3.0-beta.2", + "@connext/vector-types": "0.3.0-beta.2", + "@connext/vector-utils": "0.3.0-beta.2", "@ethersproject/abi": "5.2.0", "@ethersproject/bignumber": "5.2.0", "@ethersproject/constants": "5.2.0", @@ -29,8 +30,10 @@ "ajv": "6.12.6", "ethers": "5.2.0", "evt": "1.9.12", + "fastq": "1.11.0", "pino": "6.11.1", - "tty": "1.0.1" + "tty": "1.0.1", + "uuid": "8.3.2" }, "devDependencies": { "@types/chai": "4.2.15", diff --git a/modules/protocol/src/errors.ts b/modules/protocol/src/errors.ts index 131dd83b3..7a9dc435f 100644 --- a/modules/protocol/src/errors.ts +++ b/modules/protocol/src/errors.ts @@ -8,8 +8,39 @@ import { UpdateParams, Values, ProtocolError, + Result, } from "@connext/vector-types"; +export class RestoreError extends ProtocolError { + static readonly type = "RestoreError"; + + static readonly reasons = { + AckFailed: "Could not send restore ack", + AcquireLockError: "Failed to acquire restore lock", + ChannelNotFound: "Channel not found", + CouldNotGetActiveTransfers: "Failed to retrieve active transfers from store", + CouldNotGetChannel: "Failed to retrieve channel from store", + GetChannelAddressFailed: "Failed to calculate channel address for verification", + InvalidChannelAddress: "Failed to verify channel address", + InvalidMerkleRoot: "Failed to validate merkleRoot for restoration", + InvalidSignatures: "Failed to validate sigs on latestUpdate", + NoData: "No data sent from counterparty to restore", + ReceivedError: "Got restore error from counterparty", + ReleaseLockError: "Failed to release restore lock", + SaveChannelFailed: "Failed to save channel state", + SyncableState: "Cannot restore, state is syncable. Try reconcileDeposit", + } as const; + + constructor( + public readonly message: Values, + channel: FullChannelState, + publicIdentifier: string, + context: any = {}, + ) { + super(message, channel, undefined, undefined, { publicIdentifier, ...context }, RestoreError.type); + } +} + export class ValidationError extends ProtocolError { static readonly type = "ValidationError"; @@ -27,6 +58,7 @@ export class ValidationError extends ProtocolError { InvalidChannelAddress: "Provided channel address is invalid", InvalidCounterparty: "Channel counterparty is invalid", InvalidInitialState: "Initial transfer state is invalid", + InvalidProtocolVersion: "Protocol version is invalid", InvalidResolver: "Transfer resolver must be an object", LongChannelTimeout: `Channel timeout above maximum of ${MAXIMUM_CHANNEL_TIMEOUT.toString()}s`, OnlyResponderCanInitiateResolve: "Only transfer responder may initiate resolve update", @@ -38,6 +70,7 @@ export class ValidationError extends ProtocolError { TransferTimeoutBelowMin: `Transfer timeout below minimum of ${MINIMUM_TRANSFER_TIMEOUT.toString()}s`, TransferTimeoutAboveMax: `Transfer timeout above maximum of ${MAXIMUM_TRANSFER_TIMEOUT.toString()}s`, UnrecognizedType: "Unrecognized update type", + UpdateIdSigInvalid: "Update id signature is invalid", } as const; constructor( @@ -56,78 +89,6 @@ export class ValidationError extends ProtocolError { ); } } - -// Thrown by the protocol when applying an update -export class InboundChannelUpdateError extends ProtocolError { - static readonly type = "InboundChannelUpdateError"; - - static readonly reasons = { - ApplyAndValidateInboundFailed: "Failed to validate + apply incoming update", - ApplyUpdateFailed: "Failed to apply update", - BadSignatures: "Could not recover signers", - CannotSyncSetup: "Cannot sync a setup update, must restore", - CouldNotGetParams: "Could not generate params from update", - CouldNotGetFinalBalance: "Could not retrieve resolved balance from chain", - GenerateSignatureFailed: "Failed to generate channel signature", - ExternalValidationFailed: "Failed external inbound validation", - InvalidUpdateNonce: "Update nonce must be previousState.nonce + 1", - MalformedDetails: "Channel update details are malformed", - MalformedUpdate: "Channel update is malformed", - RestoreNeeded: "Cannot sync channel from counterparty, must restore", - SaveChannelFailed: "Failed to save channel", - StoreFailure: "Failed to pull data from store", - StaleChannel: "Channel state is behind, cannot apply update", - StaleUpdate: "Update does not progress channel nonce", - SyncFailure: "Failed to sync channel from counterparty update", - TransferNotActive: "Transfer not found in activeTransfers", - } as const; - - constructor( - public readonly message: Values, - update: ChannelUpdate, - state?: FullChannelState, - context: any = {}, - ) { - super(message, state, update, undefined, context, InboundChannelUpdateError.type); - } -} - -// Thrown by the protocol when initiating an update -export class OutboundChannelUpdateError extends ProtocolError { - static readonly type = "OutboundChannelUpdateError"; - - static readonly reasons = { - AcquireLockFailed: "Failed to acquire lock", - BadSignatures: "Could not recover signers", - CannotSyncSetup: "Cannot sync a setup update, must restore", - ChannelNotFound: "No channel found in storage", - CounterpartyFailure: "Counterparty failed to apply update", - CounterpartyOffline: "Message to counterparty timed out", - Create2Failed: "Failed to get create2 address", - ExternalValidationFailed: "Failed external outbound validation", - GenerateUpdateFailed: "Failed to generate update", - InvalidParams: "Invalid params", - NoUpdateToSync: "No update provided from responder to sync from", - OutboundValidationFailed: "Failed to validate outbound update", - RegenerateUpdateFailed: "Failed to regenerate update after sync", - ReleaseLockFailed: "Failed to release lock", - RestoreNeeded: "Cannot sync channel from counterparty, must restore", - SaveChannelFailed: "Failed to save channel", - StaleChannel: "Channel state is behind, cannot apply update", - StoreFailure: "Failed to pull data from store", - SyncFailure: "Failed to sync channel from counterparty update", - } as const; - - constructor( - public readonly message: Values, - params: UpdateParams, - state?: FullChannelState, - context: any = {}, - ) { - super(message, state, undefined, params, context, OutboundChannelUpdateError.type); - } -} - export class CreateUpdateError extends ProtocolError { static readonly type = "CreateUpdateError"; @@ -137,6 +98,7 @@ export class CreateUpdateError extends ProtocolError { CouldNotSign: "Failed to sign updated channel hash", FailedToReconcileDeposit: "Could not reconcile deposit", FailedToResolveTransferOnchain: "Could not resolve transfer onchain", + FailedToUpdateMerkleRoot: "Could not generate new merkle root", TransferNotActive: "Transfer not found in active transfers", TransferNotRegistered: "Transfer not found in registry", } as const; @@ -170,3 +132,66 @@ export class ApplyUpdateError extends ProtocolError { super(message, state, update, undefined, context, ApplyUpdateError.type); } } + +// Thrown by protocol when update added to the queue has failed. +// Thrown on inbound (other) and outbound (self) updates +export class QueuedUpdateError extends ProtocolError { + static readonly type = "QueuedUpdateError"; + + static readonly reasons = { + ApplyAndValidateInboundFailed: "Failed to validate + apply incoming update", + ApplyUpdateFailed: "Failed to apply update", + BadSignatures: "Could not recover signers", + Cancelled: "Queued update was cancelled", + CannotSyncSetup: "Cannot sync a setup update, must restore", // TODO: remove + ChannelNotFound: "Channel not found", + ChannelRestoring: "Channel is restoring, cannot update", + CouldNotGetParams: "Could not generate params from update", + CouldNotGetResolvedBalance: "Could not retrieve resolved balance from chain", + CounterpartyFailure: "Counterparty failed to apply update", + CounterpartyOffline: "Message to counterparty timed out", + Create2Failed: "Failed to get create2 address", + ExternalValidationFailed: "Failed external validation", + GenerateSignatureFailed: "Failed to generate channel signature", + GenerateUpdateFailed: "Failed to generate update", + InvalidParams: "Invalid params", + InvalidUpdateNonce: "Update nonce must be previousState.nonce + 1", + MalformedDetails: "Channel update details are malformed", + MalformedUpdate: "Channel update is malformed", + MissingTransferForUpdateInclusion: "Cannot evaluate update inclusion, missing proposed transfer", + OutboundValidationFailed: "Failed to validate outbound update", + RestoreNeeded: "Cannot sync channel from counterparty, must restore", + StaleChannel: "Channel state is behind, cannot apply update", + StaleUpdate: "Update does not progress channel nonce", + SyncFailure: "Failed to sync channel from counterparty update", + SyncSingleSigned: "Cannot sync single signed state", + StoreFailure: "Store method failed", + TransferNotActive: "Transfer not found in activeTransfers", + UnhandledPromise: "Unhandled promise rejection encountered", + UpdateIdSigInvalid: "Update id signature is invalid", + } as const; + + // TODO: improve error from result + static fromResult(result: Result, reason: Values) { + return new QueuedUpdateError(reason, { + error: result.getError()!.message, + ...((result.getError() as any)!.context ?? {}), + }); + } + + constructor( + public readonly message: Values, + attempted: UpdateParams | ChannelUpdate, + state?: FullChannelState, + context: any = {}, + ) { + super( + message, + state, + (attempted as any).fromIdentifier ? (attempted as ChannelUpdate) : undefined, // update + (attempted as any).fromIdentifier ? undefined : (attempted as UpdateParams), // params + context, + QueuedUpdateError.type, + ); + } +} diff --git a/modules/protocol/src/queue.ts b/modules/protocol/src/queue.ts new file mode 100644 index 000000000..3dcde0e03 --- /dev/null +++ b/modules/protocol/src/queue.ts @@ -0,0 +1,266 @@ +import { UpdateParams, UpdateType, Result, ChannelUpdate } from "@connext/vector-types"; +import { getNextNonceForUpdate } from "./utils"; + +type Nonce = number; + +// A node for FifoQueue +class FifoNode { + prev: FifoNode | undefined; + value: T; + constructor(value: T) { + this.value = value; + } +} + +// A very simple FifoQueue. +// After looking at a couple unsatisfactory npm +// dependencies it seemed easier to just write this. :/ +class FifoQueue { + head: FifoNode | undefined; + tail: FifoNode | undefined; + + push(value: T) { + const node = new FifoNode(value); + if (this.head === undefined) { + this.head = node; + this.tail = node; + } else { + this.tail!.prev = node; + this.tail = node; + } + } + + peek(): T | undefined { + if (this.head === undefined) { + return undefined; + } + return this.head.value; + } + + pop(): T | undefined { + if (this.head === undefined) { + return undefined; + } + const value = this.head.value; + this.head = this.head.prev; + if (this.head === undefined) { + this.tail = undefined; + } + return value; + } +} + +// A manually resolvable promise. +// When using this, be aware of "throw-safety". +class Resolver { + // @ts-ignore: This is assigned in the constructor + readonly resolve: (value: O) => void; + + isResolved: boolean = false; + + // @ts-ignore: This is assigned in the constructor + readonly reject: (reason?: any) => void; + + readonly promise: Promise; + + constructor() { + this.promise = new Promise((resolve, reject) => { + // @ts-ignore Assigning to readonly in constructor + this.resolve = (output: O) => { + this.isResolved = true; + resolve(output); + }; + // @ts-ignore Assigning to readonly in constructor + this.reject = reject; + }); + } +} + +export type SelfUpdate = { + params: UpdateParams; +}; + +export type OtherUpdate = { + update: ChannelUpdate; + previous: ChannelUpdate; + inbox: string; +}; + +// Repeated wake-up promises. +class Waker { + private current: Resolver | undefined; + + // Wakes up all promises from previous + // calls to waitAsync() + wake() { + let current = this.current; + if (current) { + this.current = undefined; + current.resolve(undefined); + } + } + + // Wait until the next call to wake() + waitAsync(): Promise { + if (this.current === undefined) { + this.current = new Resolver(); + } + return this.current.promise; + } +} + +class Queue { + private readonly fifo: FifoQueue<[I, Resolver]> = new FifoQueue(); + + peek(): I | undefined { + return this.fifo.peek()?.[0]; + } + + // Pushes an item on the queue, returning a promise + // that resolved when the item has been popped from the + // queue (meaning it has been handled completely) + push(value: I): Promise { + let resolver = new Resolver(); + this.fifo.push([value, resolver]); + return resolver.promise; + } + + // Resolves the top item from the queue (removing it + // and resolving the promise) + resolve(output: O) { + let item = this.fifo.pop()!; + item[1].resolve(output); + } + + reject(error: any) { + let item = this.fifo.pop()!; + item[1].reject(error); + } +} + +// If the Promise resolves to undefined it has been cancelled. +export type Cancellable = (value: I, cancel: Promise) => Promise | undefined>; + +// Infallibly process an update. +// If the function fails, this rejects the queue. +// If the function cancels, this ignores the queue. +// If the function succeeds, this resolves the queue. +async function processOneUpdate( + f: Cancellable, + value: I, + cancel: Promise, + queue: Queue>, +): Promise | undefined> { + let result; + try { + result = await f(value, cancel); + } catch (e) { + queue.reject(e); + } + + // If not cancelled, resolve. + if (result !== undefined) { + queue.resolve(result); + } + + return result; +} + +export class SerializedQueue { + private readonly incomingSelf: Queue> = new Queue(); + private readonly incomingOther: Queue> = new Queue(); + private readonly waker: Waker = new Waker(); + private readonly selfIsAlice: boolean; + private wakeOn: 'self' | 'other' | 'any' | 'none' = 'any'; + + private readonly selfUpdateAsync: Cancellable; + private readonly otherUpdateAsync: Cancellable; + private readonly getCurrentNonce: () => Promise; + + constructor( + selfIsAlice: boolean, + selfUpdateAsync: Cancellable, + otherUpdateAsync: Cancellable, + getCurrentNonce: () => Promise, + ) { + this.selfIsAlice = selfIsAlice; + this.selfUpdateAsync = selfUpdateAsync; + this.otherUpdateAsync = otherUpdateAsync; + this.getCurrentNonce = getCurrentNonce; + this.processUpdatesAsync(); + } + + private wake(type: 'self' | 'other') { + if (this.wakeOn === 'any' || this.wakeOn === type) { + this.waker.wake(); + } + } + + executeSelfAsync(update: SelfUpdate): Promise> { + let promise = this.incomingSelf.push(update); + this.wake('self'); + return promise; + } + + executeOtherAsync(update: OtherUpdate): Promise> { + let promise = this.incomingOther.push(update); + this.wake('other'); + return promise; + } + + private async processUpdatesAsync(): Promise { + while (true) { + // Clear memory from any previous promises. + // This is important because if passed to Promise.race + // the memory held by that won't clear until the promise + // is resolved (which can be indefinite). + this.waker.wake(); + + // This await has to happen here because we don't want the + // waker to be disturbed after it's cleared. Otherwise we + // might wake on the wrong types since wakeOn might not + // be set correctly. + const currentNonce = await this.getCurrentNonce(); + + const self = this.incomingSelf.peek(); + const other = this.incomingOther.peek(); + const wake = this.waker.waitAsync(); + + if (self === undefined && other === undefined) { + this.wakeOn = 'any'; + await wake; + continue; + } + + const selfPredictedNonce = getNextNonceForUpdate(currentNonce, this.selfIsAlice); + const otherPredictedNonce = getNextNonceForUpdate(currentNonce, !this.selfIsAlice); + + if (selfPredictedNonce > otherPredictedNonce) { + // Our update has priority. If we have an update, + // execute it without interruption. Otherwise, + // execute their update with interruption + if (self !== undefined) { + this.wakeOn = 'none'; + await processOneUpdate(this.selfUpdateAsync, self, wake, this.incomingSelf); + } else { + // TODO: In the case that our update cancels theirs, we already know their + // update will fail because it doesn't include ours (unless they reject our update) + // So, this may end up falling back to the sync protocol unnecessarily when we + // try to execute their update after ours. For robustness sake, it's probably + // best to leave this as-is and optimize that case later. + this.wakeOn = 'self'; + await processOneUpdate(this.otherUpdateAsync, other!, wake, this.incomingOther); + } + } else { + // Their update has priority. Vice-versa from above + if (other !== undefined) { + this.wakeOn = 'none'; + await processOneUpdate(this.otherUpdateAsync, other, wake, this.incomingOther); + } else { + this.wakeOn = 'other'; + await processOneUpdate(this.selfUpdateAsync, self!, wake, this.incomingSelf); + } + } + } + } +} diff --git a/modules/protocol/src/sync.ts b/modules/protocol/src/sync.ts index 3699111de..c93e5997b 100644 --- a/modules/protocol/src/sync.ts +++ b/modules/protocol/src/sync.ts @@ -1,6 +1,5 @@ import { ChannelUpdate, - IVectorStore, UpdateType, IMessagingService, FullChannelState, @@ -13,49 +12,59 @@ import { IExternalValidation, MessagingError, jsonifyError, + PROTOCOL_VERSION, } from "@connext/vector-types"; -import { getRandomBytes32, LOCK_TTL } from "@connext/vector-utils"; +import { getRandomBytes32 } from "@connext/vector-utils"; import pino from "pino"; -import { InboundChannelUpdateError, OutboundChannelUpdateError } from "./errors"; -import { extractContextFromStore, validateChannelSignatures } from "./utils"; +import { QueuedUpdateError } from "./errors"; +import { getNextNonceForUpdate, validateChannelSignatures } from "./utils"; import { validateAndApplyInboundUpdate, validateParamsAndApplyUpdate } from "./validate"; // Function responsible for handling user-initated/outbound channel updates. // These updates will be single signed, the function should dispatch the // message to the counterparty, and resolve once the updated channel state -// has been persisted. +// has been received. Will be persisted within the queue to avoid race +// conditions around a double signed update being received but *not* yet +// saved before being cancelled +type UpdateResult = { + updatedChannel: FullChannelState; + updatedTransfers?: FullTransferState[]; + updatedTransfer?: FullTransferState; +}; + +export type SelfUpdateResult = UpdateResult & { + successfullyApplied: "synced" | "executed" | "previouslyExecuted"; +}; + export async function outbound( params: UpdateParams, - storeService: IVectorStore, + activeTransfers: FullTransferState[], + previousState: FullChannelState | undefined, chainReader: IVectorChainReader, messagingService: IMessagingService, externalValidationService: IExternalValidation, signer: IChannelSigner, logger: pino.BaseLogger, -): Promise< - Result< - { updatedChannel: FullChannelState; updatedTransfers?: FullTransferState[]; updatedTransfer?: FullTransferState }, - OutboundChannelUpdateError - > -> { +): Promise> { const method = "outbound"; const methodId = getRandomBytes32(); logger.debug({ method, methodId }, "Method start"); - // First, pull all information out from the store - const storeRes = await extractContextFromStore(storeService, params.channelAddress); - if (storeRes.isError) { - return Result.fail( - new OutboundChannelUpdateError(OutboundChannelUpdateError.reasons.StoreFailure, params, undefined, { - storeError: storeRes.getError()?.message, - method, - }), - ); - } - - // eslint-disable-next-line prefer-const - let { activeTransfers, channelState: previousState } = storeRes.getValue(); + logger.warn( + { + method, + methodId, + ourLatestNonce: previousState?.nonce ?? 0, + updateNonce: getNextNonceForUpdate( + previousState?.nonce ?? 0, + signer.publicIdentifier === previousState?.aliceIdentifier ?? true, + ), + alice: previousState?.aliceIdentifier ?? signer.publicIdentifier, + updateInitiator: signer.publicIdentifier, + }, + "Preparing outbound update", + ); // Ensure parameters are valid, and action can be taken const updateRes = await validateParamsAndApplyUpdate( @@ -88,6 +97,7 @@ export async function outbound( // Send and wait for response logger.debug({ method, methodId, to: update.toIdentifier, type: update.type }, "Sending protocol message"); let counterpartyResult = await messagingService.sendProtocolMessage( + PROTOCOL_VERSION, update, previousState?.latestUpdate, // LOCK_TTL / 10, @@ -97,24 +107,57 @@ export async function outbound( // IFF the result failed because the update is stale, our channel is behind // so we should try to sync the channel and resend the update let error = counterpartyResult.getError(); - if (error && error.message === InboundChannelUpdateError.reasons.StaleUpdate) { + if (error && error.message !== QueuedUpdateError.reasons.StaleUpdate) { + // Error is something other than sync, fail + logger.error( + { method, methodId, counterpartyError: jsonifyError(error), previousState, update, params }, + "Error receiving response, will not save state!", + ); + return Result.fail( + new QueuedUpdateError( + error.message === MessagingError.reasons.Timeout + ? QueuedUpdateError.reasons.CounterpartyOffline + : QueuedUpdateError.reasons.CounterpartyFailure, + params, + previousState, + { + counterpartyError: jsonifyError(error), + }, + ), + ); + } + if (error && error.message === QueuedUpdateError.reasons.StaleUpdate) { + // Handle sync error, then return failure logger.warn( { method, methodId, - proposed: update.nonce, + ourLatestNonce: previousState?.nonce ?? 0, + updateNonce: update.nonce, + alice: previousState?.aliceIdentifier ?? signer.publicIdentifier, + updateInitiator: signer.publicIdentifier, + toSyncIdentifier: error.context.state.latestUpdate.fromIdentifier, + toSyncNonce: error.context.state.latestUpdate.nonce, error: jsonifyError(error), + expectedNextNonce: getNextNonceForUpdate( + previousState?.nonce ?? 0, + previousState?.aliceIdentifier === error.context.state.latestUpdate.fromIdentifier, + ), }, - `Behind, syncing and retrying`, + "Behind, syncing then cancelling proposed", ); // Get the synced state and new update - const syncedResult = await syncStateAndRecreateUpdate( - error as InboundChannelUpdateError, - params, + const syncedResult = await syncState( + error.context.state.latestUpdate, previousState!, // safe to do bc will fail if syncing setup (only time state is undefined) activeTransfers, - storeService, + (message: Values) => + Result.fail( + new QueuedUpdateError(message, params, previousState, { + syncError: message, + }), + ), chainReader, externalValidationService, signer, @@ -126,36 +169,19 @@ export async function outbound( return Result.fail(syncedResult.getError()!); } - // Retry sending update to counterparty - const sync = syncedResult.getValue()!; - counterpartyResult = await messagingService.sendProtocolMessage(sync.update, sync.updatedChannel.latestUpdate); - - // Update error values + stored channel value - error = counterpartyResult.getError(); - previousState = sync.syncedChannel; - update = sync.update; - updatedChannel = sync.updatedChannel; - updatedTransfer = sync.updatedTransfer; - updatedActiveTransfers = sync.updatedActiveTransfers; - } - - // Error object should now be either the error from trying to sync, or the - // original error. Either way, we do not want to handle it - if (error) { - // Error is for some other reason, do not retry update. - logger.error({ method, methodId, error: jsonifyError(error) }, "Error receiving response, will not save state!"); - return Result.fail( - new OutboundChannelUpdateError( - error.message === MessagingError.reasons.Timeout - ? OutboundChannelUpdateError.reasons.CounterpartyOffline - : OutboundChannelUpdateError.reasons.CounterpartyFailure, - params, - previousState, - { - counterpartyError: jsonifyError(error), - }, - ), - ); + // Return that proposed update was not successfully applied, but + // make sure to save state + const { + updatedChannel: syncedChannel, + updatedTransfer: syncedTransfer, + updatedActiveTransfers: syncedActiveTransfers, + } = syncedResult.getValue()!; + return Result.ok({ + updatedChannel: syncedChannel, + updatedActiveTransfers: syncedActiveTransfers, + updatedTransfer: syncedTransfer, + successfullyApplied: "synced", + }); } logger.debug({ method, methodId, to: update.toIdentifier, type: update.type }, "Received protocol response"); @@ -171,166 +197,144 @@ export async function outbound( logger, ); if (sigRes.isError) { - const error = new OutboundChannelUpdateError( - OutboundChannelUpdateError.reasons.BadSignatures, - params, - previousState, - { recoveryError: sigRes.getError()?.message }, + logger.error( + { method, update, counterpartyUpdate, error: jsonifyError(sigRes.getError()!) }, + "Failed to recover signer", ); - logger.error({ method, error: jsonifyError(error) }, "Error receiving response, will not save state!"); + const error = new QueuedUpdateError(QueuedUpdateError.reasons.BadSignatures, params, previousState, { + recoveryError: sigRes.getError()?.message, + }); return Result.fail(error); } - try { - await storeService.saveChannelState({ ...updatedChannel, latestUpdate: counterpartyUpdate }, updatedTransfer); - logger.debug({ method, methodId }, "Method complete"); - return Result.ok({ - updatedChannel: { ...updatedChannel, latestUpdate: counterpartyUpdate }, - updatedTransfers: updatedActiveTransfers, - updatedTransfer, - }); - } catch (e) { - return Result.fail( - new OutboundChannelUpdateError( - OutboundChannelUpdateError.reasons.SaveChannelFailed, - params, - { ...updatedChannel, latestUpdate: counterpartyUpdate }, - { - saveChannelError: e.message, - }, - ), - ); - } + return Result.ok({ + updatedChannel: { ...updatedChannel, latestUpdate: counterpartyUpdate }, + updatedTransfers: updatedActiveTransfers, + updatedTransfer, + successfullyApplied: "executed", + }); } +export type OtherUpdateResult = UpdateResult & { + previousState?: FullChannelState; +}; + export async function inbound( update: ChannelUpdate, previousUpdate: ChannelUpdate, - inbox: string, + activeTransfers: FullTransferState[], + channel: FullChannelState | undefined, chainReader: IVectorChainReader, - storeService: IVectorStore, - messagingService: IMessagingService, externalValidation: IExternalValidation, signer: IChannelSigner, logger: pino.BaseLogger, -): Promise< - Result< - { - updatedChannel: FullChannelState; - updatedActiveTransfers?: FullTransferState[]; - updatedTransfer?: FullTransferState; - }, - InboundChannelUpdateError - > -> { +): Promise> { const method = "inbound"; const methodId = getRandomBytes32(); logger.debug({ method, methodId }, "Method start"); // Create a helper to handle errors so the message is sent // properly to the counterparty const returnError = async ( - reason: Values, + reason: Values, prevUpdate: ChannelUpdate = update, state?: FullChannelState, context: any = {}, - ): Promise> => { + ): Promise> => { logger.error( { method, methodId, channel: update.channelAddress, error: reason, context }, "Error responding to channel update", ); - const error = new InboundChannelUpdateError(reason, prevUpdate, state, context); - await messagingService.respondWithProtocolError(inbox, error); + const error = new QueuedUpdateError(reason, prevUpdate, state, context); return Result.fail(error); }; - const storeRes = await extractContextFromStore(storeService, update.channelAddress); - if (storeRes.isError) { - return returnError(InboundChannelUpdateError.reasons.StoreFailure, undefined, undefined, { - storeError: storeRes.getError()?.message, - }); - } - - // eslint-disable-next-line prefer-const - let { activeTransfers, channelState: channelFromStore } = storeRes.getValue(); - // Now that you have a valid starting state, you can try to apply the - // update, and sync if necessary. - // Assume that our stored state has nonce `k`, and the update - // has nonce `n`, and `k` is the latest double signed state for you. The - // following cases exist: - // - n <= k - 2: counterparty is behind, they must restore - // - n == k - 1: counterparty is behind, they will sync and recover, we - // can ignore update - // - n == k, single signed: counterparty is behind, ignore update - // - n == k, double signed: - // - IFF the states are the same, the counterparty is behind - // - IFF the states are different and signed at the same nonce, - // that is VERY bad, and should NEVER happen - // - n == k + 1, single signed: counterparty proposing an update, - // we should verify, store, + ack - // - n == k + 1, double signed: counterparty acking our update, - // we should verify, store, + emit - // - n == k + 2: counterparty is proposing or acking on top of a - // state we do not yet have, sync state + apply update - // - n >= k + 3: we must restore state - + // update, and sync if necessary. The following cases exist: + // (a) counterparty is behind, and they must restore (>1 transition behind) + // (b) counterparty is behind, but their state is syncable (1 transition + // behind) + // (c) we are in sync, can apply update directly + // (d) we are behind, and must sync before applying update (1 transition + // behind) + // (e) we are behind, and must restore before applying update (>1 + // transition behind) + + // Nonce transitions for these cases (given previous update = n, our + // previous update = k): + // (a,b) n > k -- try to sync, restore case handled in syncState + // (c) n === k -- perform update, channels in sync + // (d,e) n < k -- counterparty behind, restore handled in their sync // Get the difference between the stored and received nonces - const prevNonce = channelFromStore?.nonce ?? 0; - const diff = update.nonce - prevNonce; + const ourPreviousNonce = channel?.latestUpdate?.nonce ?? -1; + + // Get the expected previous update nonce + const givenPreviousNonce = previousUpdate?.nonce ?? -1; + + logger.warn( + { + method, + methodId, + ourLatestNonce: channel?.nonce ?? 0, + updateNonce: update.nonce, + alice: channel?.aliceIdentifier ?? update.fromIdentifier, + updateInitiator: update.fromIdentifier, + ourIdentifier: signer.publicIdentifier, + expectedNextNonce: getNextNonceForUpdate(channel?.nonce ?? 0, update.fromIdentifier === channel?.aliceIdentifier), + givenPreviousNonce, + ourPreviousNonce, + }, + "Handling inbound update", + ); - // If we are ahead, or even, do not process update - if (diff <= 0) { + if (givenPreviousNonce < ourPreviousNonce) { // NOTE: when you are out of sync as a protocol initiator, you will // use the information from this error to sync, then retry your update - return returnError(InboundChannelUpdateError.reasons.StaleUpdate, channelFromStore!.latestUpdate, channelFromStore); - } - - // If we are behind by more than 3, we cannot sync from their latest - // update, and must use restore - if (diff >= 3) { - return returnError(InboundChannelUpdateError.reasons.RestoreNeeded, update, channelFromStore, { - counterpartyLatestUpdate: previousUpdate, - ourLatestNonce: prevNonce, - }); + return returnError(QueuedUpdateError.reasons.StaleUpdate, channel!.latestUpdate, channel); } - // If the update nonce is ahead of the store nonce by 2, we are - // behind by one update. We can progress the state to the correct - // state to be updated by applying the counterparty's supplied - // latest action - let previousState = channelFromStore ? { ...channelFromStore } : undefined; - if (diff === 2) { + let previousState = channel ? { ...channel } : undefined; + if (givenPreviousNonce > ourPreviousNonce) { // Create the proper state to play the update on top of using the // latest update if (!previousUpdate) { - return returnError(InboundChannelUpdateError.reasons.StaleChannel, previousUpdate, previousState); + return returnError(QueuedUpdateError.reasons.StaleChannel, previousUpdate, previousState); } + logger.warn( + { + method, + methodId, + ourLatestNonce: channel?.nonce ?? 0, + updateNonce: update.nonce, + alice: channel?.aliceIdentifier ?? update.fromIdentifier, + updateInitiator: update.fromIdentifier, + ourIdentifier: signer.publicIdentifier, + toSyncIdentifier: previousUpdate.fromIdentifier, + toSyncNonce: givenPreviousNonce, + expectedNextNonce: getNextNonceForUpdate( + channel?.nonce ?? 0, + previousUpdate.fromIdentifier === channel?.aliceIdentifier, + ), + }, + "Behind, syncing", + ); const syncRes = await syncState( previousUpdate, previousState!, activeTransfers, - (message: string) => + (message: Values) => Result.fail( - new InboundChannelUpdateError( - message !== InboundChannelUpdateError.reasons.CannotSyncSetup - ? InboundChannelUpdateError.reasons.SyncFailure - : InboundChannelUpdateError.reasons.CannotSyncSetup, - previousUpdate, - previousState, - { - syncError: message, - }, - ), + new QueuedUpdateError(message, previousUpdate, previousState, { + syncError: message, + }), ), - storeService, chainReader, externalValidation, signer, logger, ); if (syncRes.isError) { - const error = syncRes.getError() as InboundChannelUpdateError; + const error = syncRes.getError() as QueuedUpdateError; return returnError(error.message, error.context.update, error.context.state as FullChannelState, error.context); } @@ -341,6 +345,8 @@ export async function inbound( activeTransfers = syncedActiveTransfers; } + // Should be fully in sync, safe to apply provided update + // We now have the latest state for the update, and should be // able to play it on top of the update const validateRes = await validateAndApplyInboundUpdate( @@ -359,151 +365,15 @@ export async function inbound( const { updatedChannel, updatedActiveTransfers, updatedTransfer } = validateRes.getValue(); - // Save the newly signed update to your channel - try { - await storeService.saveChannelState(updatedChannel, updatedTransfer); - } catch (e) { - return returnError(InboundChannelUpdateError.reasons.SaveChannelFailed, update, previousState, { - saveChannelError: e.message, - }); - } - - // Send response to counterparty - await messagingService.respondToProtocolMessage( - inbox, - updatedChannel.latestUpdate, - previousState ? previousState!.latestUpdate : undefined, - ); - // Return the double signed state - return Result.ok({ updatedActiveTransfers, updatedChannel, updatedTransfer }); + return Result.ok({ updatedTransfers: updatedActiveTransfers, updatedChannel, updatedTransfer, previousState }); } -// This function should be called in `outbound` by an update initiator -// after they have received an error from their counterparty indicating -// that the update nonce was stale (i.e. `myChannel` is behind). In this -// case, you should try to play the update and regenerate the attempted -// update to send to the counterparty -type OutboundSync = { - update: ChannelUpdate; - syncedChannel: FullChannelState; - updatedChannel: FullChannelState; - updatedTransfer?: FullTransferState; - updatedActiveTransfers: FullTransferState[]; -}; - -const syncStateAndRecreateUpdate = async ( - receivedError: InboundChannelUpdateError, - attemptedParams: UpdateParams, - previousState: FullChannelState, - activeTransfers: FullTransferState[], - storeService: IVectorStore, - chainReader: IVectorChainReader, - externalValidationService: IExternalValidation, - signer: IChannelSigner, - logger?: pino.BaseLogger, -): Promise> => { - // When receiving an update to sync from your counterparty, you - // must make sure you can safely apply the update to your existing - // channel, and regenerate the requested update from the user-supplied - // parameters. - - const counterpartyUpdate = receivedError.context.update; - if (!counterpartyUpdate) { - return Result.fail( - new OutboundChannelUpdateError( - OutboundChannelUpdateError.reasons.NoUpdateToSync, - attemptedParams, - previousState, - { receivedError: jsonifyError(receivedError) }, - ), - ); - } - - // make sure you *can* sync - const diff = counterpartyUpdate.nonce - (previousState?.nonce ?? 0); - if (diff !== 1) { - return Result.fail( - new OutboundChannelUpdateError(OutboundChannelUpdateError.reasons.RestoreNeeded, attemptedParams, previousState, { - counterpartyUpdate, - latestNonce: previousState.nonce, - }), - ); - } - - const syncRes = await syncState( - counterpartyUpdate, - previousState, - activeTransfers, - (message: string) => - Result.fail( - new OutboundChannelUpdateError( - message !== InboundChannelUpdateError.reasons.CannotSyncSetup - ? OutboundChannelUpdateError.reasons.SyncFailure - : OutboundChannelUpdateError.reasons.CannotSyncSetup, - attemptedParams, - previousState, - { - syncError: message, - }, - ), - ), - storeService, - chainReader, - externalValidationService, - signer, - logger, - ); - if (syncRes.isError) { - return Result.fail(syncRes.getError() as OutboundChannelUpdateError); - } - - const { updatedChannel: syncedChannel, updatedActiveTransfers: syncedActiveTransfers } = syncRes.getValue(); - - // Regenerate the proposed update - // Must go through validation again to ensure it is still a valid update - // against the newly synced channel - const validationRes = await validateParamsAndApplyUpdate( - signer, - chainReader, - externalValidationService, - attemptedParams, - syncedChannel, - syncedActiveTransfers, - signer.publicIdentifier, - logger, - ); - - if (validationRes.isError) { - const { - state: errState, - params: errParams, - update: errUpdate, - ...usefulContext - } = validationRes.getError()?.context; - return Result.fail( - new OutboundChannelUpdateError( - OutboundChannelUpdateError.reasons.RegenerateUpdateFailed, - attemptedParams, - syncedChannel, - { - regenerateUpdateError: validationRes.getError()!.message, - regenerateUpdateContext: usefulContext, - }, - ), - ); - } - - // Return the updated channel state and the regenerated update - return Result.ok({ ...validationRes.getValue(), syncedChannel }); -}; - const syncState = async ( toSync: ChannelUpdate, previousState: FullChannelState, activeTransfers: FullTransferState[], - handleError: (message: string) => Result, - storeService: IVectorStore, + handleError: (message: Values) => Result, chainReader: IVectorChainReader, externalValidation: IExternalValidation, signer: IChannelSigner, @@ -516,7 +386,7 @@ const syncState = async ( // channel properly, we will have to handle the retry in the calling // function, so just ignore for now. if (toSync.type === UpdateType.setup) { - return handleError(InboundChannelUpdateError.reasons.CannotSyncSetup); + return handleError(QueuedUpdateError.reasons.CannotSyncSetup); } // As you receive an update to sync, it should *always* be double signed. @@ -525,7 +395,14 @@ const syncState = async ( // Present signatures are already asserted to be valid via the validation, // here simply assert the length if (!toSync.aliceSignature || !toSync.bobSignature) { - return handleError("Cannot sync single signed state"); + return handleError(QueuedUpdateError.reasons.SyncSingleSigned); + } + + // Make sure the nonce is only one transition from what we expect. + // If not, we must restore. + const expected = getNextNonceForUpdate(previousState.nonce, toSync.fromIdentifier === previousState.aliceIdentifier); + if (toSync.nonce !== expected) { + return handleError(QueuedUpdateError.reasons.RestoreNeeded); } // Apply the update + validate the signatures (NOTE: full validation is not @@ -543,14 +420,6 @@ const syncState = async ( return handleError(validateRes.getError()!.message); } - // Save synced state - const { updatedChannel: syncedChannel, updatedTransfer } = validateRes.getValue()!; - try { - await storeService.saveChannelState(syncedChannel, updatedTransfer); - } catch (e) { - return handleError(e.message); - } - // Return synced state return Result.ok(validateRes.getValue()); }; diff --git a/modules/protocol/src/testing/integration/create.spec.ts b/modules/protocol/src/testing/integration/create.spec.ts index da1240914..c2f4569e8 100644 --- a/modules/protocol/src/testing/integration/create.spec.ts +++ b/modules/protocol/src/testing/integration/create.spec.ts @@ -6,6 +6,7 @@ import { BigNumber } from "@ethersproject/bignumber"; import { env } from "../env"; import { createTransfer, getFundedChannel, depositInChannel } from "../utils"; +import { getNextNonceForUpdate } from "../../utils"; const testName = "Create Integrations"; const { log } = getTestLoggers(testName, env.logLevel); @@ -193,17 +194,19 @@ describe(testName, () => { ); await runTest(channel, transfer); - expect(channel.nonce).to.be.eq(initial!.nonce + 2); + const expected = getNextNonceForUpdate(getNextNonceForUpdate(initial!.nonce, true), true); + expect(channel.nonce).to.be.eq(expected); }); it("should work if responder channel is out of sync", async () => { const initial = await aliceStore.getChannelState(abChannelAddress); - await depositInChannel(abChannelAddress, bob, bobSigner, alice, assetId, depositAmount); + const depositChannel = await depositInChannel(abChannelAddress, bob, bobSigner, alice, assetId, depositAmount); await bobStore.saveChannelState(initial!); const { channel, transfer } = await createTransfer(abChannelAddress, alice, bob, assetId, transferAmount); await runTest(channel, transfer); - expect(channel.nonce).to.be.eq(initial!.nonce + 2); + const expected = getNextNonceForUpdate(depositChannel.nonce, true); + expect(channel.nonce).to.be.eq(expected); }); }); diff --git a/modules/protocol/src/testing/integration/deposit.spec.ts b/modules/protocol/src/testing/integration/deposit.spec.ts index e230a04c2..eef944f7d 100644 --- a/modules/protocol/src/testing/integration/deposit.spec.ts +++ b/modules/protocol/src/testing/integration/deposit.spec.ts @@ -6,6 +6,7 @@ import { AddressZero } from "@ethersproject/constants"; import { deployChannelIfNeeded, depositInChannel, depositOnchain, getSetupChannel } from "../utils"; import { env } from "../env"; import { chainId } from "../constants"; +import { getNextNonceForUpdate } from "../../utils"; const testName = "Deposit Integrations"; const { log } = getTestLoggers(testName, env.logLevel); @@ -259,7 +260,6 @@ describe(testName, () => { ]); expect(finalAlice).to.be.deep.eq(finalBob); expect(finalAlice).to.containSubset({ - nonce: preDepositChannel.nonce + 2, assetIds: [AddressZero], balances: [ { @@ -284,7 +284,8 @@ describe(testName, () => { assetId, depositAmount, ); - expect(final.nonce).to.be.eq(preDepositChannel.nonce + 2); + const expected = getNextNonceForUpdate(getNextNonceForUpdate(preDepositChannel.nonce, true), true); + expect(final.nonce).to.be.eq(expected); }); it("should work if responder channel is out of sync", async () => { @@ -300,6 +301,7 @@ describe(testName, () => { assetId, depositAmount, ); - expect(final.nonce).to.be.eq(preDepositChannel.nonce + 2); + const expected = getNextNonceForUpdate(getNextNonceForUpdate(preDepositChannel.nonce, false), false); + expect(final.nonce).to.be.eq(expected); }); }); diff --git a/modules/protocol/src/testing/integration/resolve.spec.ts b/modules/protocol/src/testing/integration/resolve.spec.ts index 32e5b387c..b800c2fcd 100644 --- a/modules/protocol/src/testing/integration/resolve.spec.ts +++ b/modules/protocol/src/testing/integration/resolve.spec.ts @@ -6,6 +6,7 @@ import { IVectorStore, IChannelSigner, FullTransferState, + FullChannelState, } from "@connext/vector-types"; import { AddressZero } from "@ethersproject/constants"; import { BigNumber } from "@ethersproject/bignumber"; @@ -13,6 +14,7 @@ import { BigNumber } from "@ethersproject/bignumber"; import { createTransfer, getFundedChannel, resolveTransfer, depositInChannel } from "../utils"; import { env } from "../env"; import { chainId } from "../constants"; +import { QueuedUpdateError } from "../../errors"; const testName = "Resolve Integrations"; const { log } = getTestLoggers(testName, env.logLevel); @@ -23,13 +25,14 @@ describe(testName, () => { let channelAddress: string; let aliceSigner: IChannelSigner; let bobSigner: IChannelSigner; - let aliceStore: IVectorStore; let bobStore: IVectorStore; let assetId: string; let assetIdErc20: string; let transferAmount: any; + let setupChannel: FullChannelState; + beforeEach(async () => { const setup = await getFundedChannel(testName, [ { @@ -43,7 +46,6 @@ describe(testName, () => { ]); alice = setup.alice.protocol; aliceSigner = setup.alice.signer; - aliceStore = setup.alice.store; bob = setup.bob.protocol; bobSigner = setup.bob.signer; bobStore = setup.bob.store; @@ -54,6 +56,8 @@ describe(testName, () => { assetIdErc20 = env.chainAddresses[chainId].testTokenAddress; transferAmount = "7"; + setupChannel = setup.channel; + log.info({ alice: alice.publicIdentifier, bob: bob.publicIdentifier, @@ -65,7 +69,7 @@ describe(testName, () => { await bob.off(); }); - const resolveTransferAlice = async (transfer: FullTransferState): Promise => { + const resolveTransferCreatedByAlice = async (transfer: FullTransferState): Promise => { const alicePromise = alice.waitFor(ProtocolEventName.CHANNEL_UPDATE_EVENT, 10_000); const bobPromise = bob.waitFor(ProtocolEventName.CHANNEL_UPDATE_EVENT, 10_000); await resolveTransfer(channelAddress, transfer, bob, alice); @@ -85,7 +89,7 @@ describe(testName, () => { expect(bobEvent.updatedTransfer?.transferState.balance).to.be.deep.eq(transfer.balance); }; - const resolveTransferBob = async (transfer: FullTransferState): Promise => { + const resolveTransferCreatedByBob = async (transfer: FullTransferState): Promise => { const alicePromise = alice.waitFor(ProtocolEventName.CHANNEL_UPDATE_EVENT, 10_000); const bobPromise = bob.waitFor(ProtocolEventName.CHANNEL_UPDATE_EVENT, 10_000); await resolveTransfer(channelAddress, transfer, alice, bob); @@ -108,48 +112,48 @@ describe(testName, () => { it("should work for alice resolving an eth transfer", async () => { const { transfer } = await createTransfer(channelAddress, alice, bob, assetId, transferAmount); - await resolveTransferAlice(transfer); + await resolveTransferCreatedByAlice(transfer); }); it("should work for alice resolving a token transfer", async () => { const { transfer } = await createTransfer(channelAddress, alice, bob, assetIdErc20, transferAmount); - await resolveTransferAlice(transfer); + await resolveTransferCreatedByAlice(transfer); }); it("should work for alice resolving an eth transfer out of channel", async () => { const outsiderPayee = mkAddress("0xc"); const { transfer } = await createTransfer(channelAddress, alice, bob, assetId, transferAmount, outsiderPayee); - await resolveTransferAlice(transfer); + await resolveTransferCreatedByAlice(transfer); }); it("should work for alice resolving a token transfer out of channel", async () => { const outsiderPayee = mkAddress("0xc"); const { transfer } = await createTransfer(channelAddress, alice, bob, assetIdErc20, transferAmount, outsiderPayee); - await resolveTransferAlice(transfer); + await resolveTransferCreatedByAlice(transfer); }); it("should work for bob resolving an eth transfer", async () => { const { transfer } = await createTransfer(channelAddress, bob, alice, assetId, transferAmount); - await resolveTransferBob(transfer); + await resolveTransferCreatedByBob(transfer); }); it("should work for bob resolving an eth transfer out of channel", async () => { const outsiderPayee = mkAddress("0xc"); const { transfer } = await createTransfer(channelAddress, bob, alice, assetId, transferAmount, outsiderPayee); - await resolveTransferBob(transfer); + await resolveTransferCreatedByBob(transfer); }); it("should work for bob resolving a token transfer", async () => { const { transfer } = await createTransfer(channelAddress, bob, alice, assetIdErc20, transferAmount); - await resolveTransferBob(transfer); + await resolveTransferCreatedByBob(transfer); }); it("should work for bob resolving a token transfer out of channel", async () => { const outsiderPayee = mkAddress("0xc"); const { transfer } = await createTransfer(channelAddress, bob, alice, assetIdErc20, transferAmount, outsiderPayee); - await resolveTransferBob(transfer); + await resolveTransferCreatedByBob(transfer); }); it("should work concurrently", async () => { @@ -167,20 +171,57 @@ describe(testName, () => { it("should work if initiator channel is out of sync", async () => { const depositAmount = BigNumber.from("1000"); const preChannelState = await depositInChannel(channelAddress, alice, aliceSigner, bob, assetId, depositAmount); - const { transfer } = await createTransfer(channelAddress, alice, bob, assetId, transferAmount); + const { transfer, channel } = await createTransfer(channelAddress, alice, bob, assetId, transferAmount); + + await bobStore.saveChannelState(preChannelState); + + // bob is resolver/initiator + await resolveTransferCreatedByAlice(transfer); + }); + + it("should fail if the initiator needs to restore", async () => { + const depositAmount = BigNumber.from("1000"); + await depositInChannel(channelAddress, alice, aliceSigner, bob, assetId, depositAmount); + const { transfer, channel } = await createTransfer(channelAddress, alice, bob, assetId, transferAmount); - await aliceStore.saveChannelState(preChannelState); + await bobStore.saveChannelState(setupChannel); - await resolveTransferAlice(transfer); + // bob is resolver/initiator + const result = await bob.resolve({ + channelAddress: channel.channelAddress, + transferId: transfer.transferId, + transferResolver: transfer.transferResolver, + }); + expect(result.isError).to.be.true; + expect(result.getError()?.message).to.be.eq(QueuedUpdateError.reasons.RestoreNeeded); }); it("should work if responder channel is out of sync", async () => { const depositAmount = BigNumber.from("1000"); const preChannelState = await depositInChannel(channelAddress, bob, bobSigner, alice, assetId, depositAmount); - const { transfer } = await createTransfer(channelAddress, bob, alice, assetId, transferAmount); + const { transfer, channel } = await createTransfer(channelAddress, bob, alice, assetId, transferAmount); await bobStore.saveChannelState(preChannelState); - await resolveTransferBob(transfer); + // alice is resolver/initiator + await resolveTransferCreatedByBob(transfer); + }); + + it("should fail if the responder needs to restore", async () => { + const depositAmount = BigNumber.from("1000"); + await depositInChannel(channelAddress, bob, bobSigner, alice, assetId, depositAmount); + const { transfer } = await createTransfer(channelAddress, bob, alice, assetId, transferAmount); + + await bobStore.saveChannelState(setupChannel); + + // alice is resolver/initiator + const result = await alice.resolve({ + channelAddress, + transferId: transfer.transferId, + transferResolver: transfer.transferResolver, + }); + expect(result.isError).to.be.true; + expect(result.getError()?.message).to.be.eq(QueuedUpdateError.reasons.CounterpartyFailure); + expect(result.getError()?.context.counterpartyError.message).to.be.eq(QueuedUpdateError.reasons.RestoreNeeded); }); }); diff --git a/modules/protocol/src/testing/integration/restore.spec.ts b/modules/protocol/src/testing/integration/restore.spec.ts new file mode 100644 index 000000000..72a5b7264 --- /dev/null +++ b/modules/protocol/src/testing/integration/restore.spec.ts @@ -0,0 +1,92 @@ +import { delay, expect, getTestLoggers } from "@connext/vector-utils"; +import { FullChannelState, IChannelSigner, IVectorProtocol, IVectorStore, Result } from "@connext/vector-types"; +import { AddressZero } from "@ethersproject/constants"; + +import { createTransfer, getFundedChannel } from "../utils"; +import { env } from "../env"; +import { QueuedUpdateError } from "../../errors"; + +const testName = "Restore Integrations"; +const { log } = getTestLoggers(testName, env.logLevel); + +describe(testName, () => { + let alice: IVectorProtocol; + let bob: IVectorProtocol; + + let abChannelAddress: string; + let aliceSigner: IChannelSigner; + let aliceStore: IVectorStore; + let bobSigner: IChannelSigner; + let bobStore: IVectorStore; + let chainId: number; + + afterEach(async () => { + await alice.off(); + await bob.off(); + }); + + beforeEach(async () => { + const setup = await getFundedChannel(testName, [ + { + assetId: AddressZero, + amount: ["100", "100"], + }, + ]); + alice = setup.alice.protocol; + bob = setup.bob.protocol; + abChannelAddress = setup.channel.channelAddress; + aliceSigner = setup.alice.signer; + bobSigner = setup.bob.signer; + aliceStore = setup.alice.store; + bobStore = setup.bob.store; + chainId = setup.channel.networkContext.chainId; + + log.info({ + alice: alice.publicIdentifier, + bob: bob.publicIdentifier, + }); + }); + + it("should work with no transfers", async () => { + // remove channel + await bobStore.clear(); + + // bob should restore + const restore = await bob.restoreState({ counterpartyIdentifier: alice.publicIdentifier, chainId }); + expect(restore.getError()).to.be.undefined; + expect(restore.getValue()).to.be.deep.eq(await aliceStore.getChannelState(abChannelAddress)); + }); + + it("should work with transfers", async () => { + // install transfer + const { transfer } = await createTransfer(abChannelAddress, bob, alice, AddressZero, "1"); + + // remove channel + await bobStore.clear(); + + // bob should restore + const restore = await bob.restoreState({ counterpartyIdentifier: alice.publicIdentifier, chainId }); + + // verify results + expect(restore.getError()).to.be.undefined; + expect(restore.getValue()).to.be.deep.eq(await aliceStore.getChannelState(abChannelAddress)); + expect(await bob.getActiveTransfers(abChannelAddress)).to.be.deep.eq( + await alice.getActiveTransfers(abChannelAddress), + ); + }); + + it("should block updates when restoring", async () => { + // remove channel + await bobStore.clear(); + + // bob should restore, alice should attempt something + const [_, update] = (await Promise.all([ + bob.restoreState({ counterpartyIdentifier: alice.publicIdentifier, chainId }), + bob.deposit({ channelAddress: abChannelAddress, assetId: AddressZero }), + ])) as [Result, Result]; + + // verify update failed + expect(update.isError).to.be.true; + expect(update.getError()?.message).to.be.eq(QueuedUpdateError.reasons.ChannelRestoring); + }); +}); diff --git a/modules/protocol/src/testing/queue.spec.ts b/modules/protocol/src/testing/queue.spec.ts new file mode 100644 index 000000000..b5c699f6c --- /dev/null +++ b/modules/protocol/src/testing/queue.spec.ts @@ -0,0 +1,307 @@ +import { SerializedQueue, SelfUpdate, OtherUpdate } from "../queue"; +import { Result } from "@connext/vector-types"; +import { getNextNonceForUpdate } from "../utils"; +import { expect, delay } from "@connext/vector-utils"; + +type FakeUpdate = { nonce: number }; + +type Delayed = { __test_queue_delay__: number; error?: boolean }; +type DelayedSelfUpdate = SelfUpdate & Delayed; +type DelayedOtherUpdate = OtherUpdate & Delayed; + +class DelayedUpdater { + readonly state: ["self" | "other", FakeUpdate][] = []; + readonly isAlice: boolean; + readonly initialUpdate: FakeUpdate; + + reentrant = false; + + constructor(isAlice: boolean, initialUpdate: FakeUpdate) { + this.isAlice = isAlice; + this.initialUpdate = initialUpdate; + } + + // Asserts that the function is not re-entrant with itself or other invocations. + // This verifies the "Serialized" in "SerializedQueue". + private async notReEntrant(f: () => Promise): Promise { + expect(this.reentrant).to.be.false; + this.reentrant = true; + let result; + try { + result = await f(); + } finally { + expect(this.reentrant).to.be.true; + this.reentrant = false; + } + + return result; + } + + currentNonce(): number { + if (this.state.length == 0) { + return this.initialUpdate.nonce; + } + return this.state[this.state.length - 1][1].nonce; + } + + private isCancelledAsync(cancel: Promise, _delay: Delayed): Promise { + if (_delay.error) { + throw new Error("Delay error"); + } + return Promise.race([ + (async () => { + await delay(_delay.__test_queue_delay__); + return false; + })(), + (async () => { + await cancel; + return true; + })(), + ]); + } + + selfUpdateAsync(value: SelfUpdate, cancel: Promise): Promise | undefined> { + return this.notReEntrant(async () => { + if (await this.isCancelledAsync(cancel, value as DelayedSelfUpdate)) { + return undefined; + } + let nonce = getNextNonceForUpdate(this.currentNonce(), this.isAlice); + this.state.push(["self", { nonce }]); + return Result.ok(undefined); + }); + } + + otherUpdateAsync(value: OtherUpdate, cancel: Promise): Promise | undefined> { + return this.notReEntrant(async () => { + if (value.update.nonce !== getNextNonceForUpdate(this.currentNonce(), !this.isAlice)) { + return Result.fail({ name: "WrongNonce", message: "WrongNonce" }); + } + + if (await this.isCancelledAsync(cancel, value as DelayedOtherUpdate)) { + return undefined; + } + + this.state.push(["other", { nonce: value.update.nonce }]); + return Result.ok(undefined); + }); + } +} + +function setup(initialUpdateNonce: number = 0, isAlice: boolean = true): [DelayedUpdater, SerializedQueue] { + let updater = new DelayedUpdater(isAlice, { nonce: initialUpdateNonce }); + let queue = new SerializedQueue( + isAlice, + updater.selfUpdateAsync.bind(updater), + updater.otherUpdateAsync.bind(updater), + async () => updater.currentNonce(), + ); + return [updater, queue]; +} + +function selfUpdate(delay: number): DelayedSelfUpdate { + const delayed: Delayed = { + __test_queue_delay__: delay, + }; + return (delayed as unknown) as DelayedSelfUpdate; +} + +function otherUpdate(delay: number, nonce: number): DelayedOtherUpdate { + const delayed: Delayed & { update: FakeUpdate } = { + __test_queue_delay__: delay, + update: { nonce }, + }; + return (delayed as unknown) as DelayedOtherUpdate; +} + +describe("Simple Updates", () => { + it("Can update self when not interrupted and is the leader", async () => { + let [updater, queue] = setup(); + let result = await queue.executeSelfAsync(selfUpdate(2)); + expect(result?.isError).to.be.false; + expect(updater.state).to.be.deep.equal([["self", { nonce: 1 }]]); + }); + it("Can update self when not interrupted and is not the leader", async () => { + let [updater, queue] = setup(1); + let result = await queue.executeSelfAsync(selfUpdate(2)); + expect(result?.isError).to.be.false; + expect(updater.state).to.be.deep.equal([["self", { nonce: 4 }]]); + }); + it("Can update other when not interrupted and is not the leader", async () => { + let [updater, queue] = setup(); + let result = await queue.executeOtherAsync(otherUpdate(2, 2)); + expect(result?.isError).to.be.false; + expect(updater.state).to.be.deep.equal([["other", { nonce: 2 }]]); + }); + it("Can update other when not interrupted and is the leader", async () => { + let [updater, queue] = setup(1); + let result = await queue.executeOtherAsync(otherUpdate(2, 2)); + expect(result?.isError).to.be.false; + expect(updater.state).to.be.deep.equal([["other", { nonce: 2 }]]); + }); +}); + +describe("Interruptions", () => { + it("Re-applies own update after interruption", async () => { + let [updater, queue] = setup(); + // Create an update with a delay of 10 ms + let resultSelf = (async () => { + await queue.executeSelfAsync(selfUpdate(10)); + return "self"; + })(); + // Wait 5 ms, then interrupt + await delay(5); + // Queue the other update, which will take longer. + let resultOther = (async () => { + await queue.executeOtherAsync(otherUpdate(15, 2)); + return "other"; + })(); + + // See that the other update finishes first, and that it's promise completes first. + let first = await Promise.race([resultSelf, resultOther]); + expect(first).to.be.equal("other"); + expect(updater.state).to.be.deep.equal([["other", { nonce: 2 }]]); + + // See that our own update completes after. + await resultSelf; + expect(updater.state).to.be.deep.equal([ + ["other", { nonce: 2 }], + ["self", { nonce: 4 }], + ]); + }); + it("Discards other update after interruption", async () => { + let [updater, queue] = setup(2); + let resultOther = queue.executeOtherAsync(otherUpdate(10, 3)); + await delay(5); + let resultSelf = queue.executeSelfAsync(selfUpdate(5)); + + expect((await resultOther).isError).to.be.true; + expect((await resultSelf).isError).to.be.false; + expect(updater.state).to.be.deep.equal([["self", { nonce: 4 }]]); + }); + it("Does not interrupt self for low priority other update", async () => { + let [updater, queue] = setup(2); + let resultSelf = queue.executeSelfAsync(selfUpdate(10)); + await delay(5); + let resultOther = queue.executeOtherAsync(otherUpdate(5, 3)); + + expect((await resultOther).isError).to.be.true; + expect((await resultSelf).isError).to.be.false; + expect(updater.state).to.be.deep.equal([["self", { nonce: 4 }]]); + }); + it("Does not interrupt for low priority self update", async () => { + let [updater, queue] = setup(); + // Create an update with a delay of 10 ms + // Queue the other update, which will take longer. + let resultOther = (async () => { + await queue.executeOtherAsync(otherUpdate(10, 2)); + return "other"; + })(); + // Wait 5 ms, then interrupt + await delay(5); + let resultSelf = (async () => { + await queue.executeSelfAsync(selfUpdate(15)); + return "self"; + })(); + + // See that the other update finishes first, and that it's promise completes first. + let first = await Promise.race([resultSelf, resultOther]); + expect(first).to.be.equal("other"); + expect(updater.state).to.be.deep.equal([["other", { nonce: 2 }]]); + + // See that our own update completes after. + await resultSelf; + expect(updater.state).to.be.deep.equal([ + ["other", { nonce: 2 }], + ["self", { nonce: 4 }], + ]); + }); +}); + +describe("Sequences", () => { + it("Resolves promises at moment of resolution", async () => { + let [updater, queue] = setup(); + for (let i = 0; i < 5; i++) { + queue.executeSelfAsync(selfUpdate(0)); + } + let sixth = queue.executeSelfAsync(selfUpdate(0)); + for (let i = 0; i < 3; i++) { + queue.executeSelfAsync(selfUpdate(0)); + } + let ninth = queue.executeSelfAsync(selfUpdate(0)); + expect((await sixth).isError).to.be.false; + expect(updater.state).to.be.deep.equal([ + ["self", { nonce: 1 }], + ["self", { nonce: 4 }], + ["self", { nonce: 5 }], + ["self", { nonce: 8 }], + ["self", { nonce: 9 }], + ["self", { nonce: 12 }], + ]); + expect((await ninth).isError).to.be.false; + expect(updater.state).to.be.deep.equal([ + ["self", { nonce: 1 }], + ["self", { nonce: 4 }], + ["self", { nonce: 5 }], + ["self", { nonce: 8 }], + ["self", { nonce: 9 }], + ["self", { nonce: 12 }], + ["self", { nonce: 13 }], + ["self", { nonce: 16 }], + ["self", { nonce: 17 }], + ["self", { nonce: 20 }], + ]); + }); +}); + +describe("Errors", () => { + it("Propagates errors", async () => { + let [updater, queue] = setup(); + let first = queue.executeSelfAsync(selfUpdate(0)); + let throwing = selfUpdate(0); + throwing.error = true; + let throws = queue.executeSelfAsync(throwing); + let second = queue.executeSelfAsync(selfUpdate(0)); + + expect((await first).isError).to.be.false; + expect(updater.state).to.be.deep.equal([["self", { nonce: 1 }]]); + + let reached = false; + try { + await throws; + reached = true; + } catch (err) { + expect(err.message).to.be.equal("Delay error"); + } + expect(reached).to.be.false; + expect(updater.state).to.be.deep.equal([["self", { nonce: 1 }]]); + + await second; + + expect(updater.state).to.be.deep.equal([ + ["self", { nonce: 1 }], + ["self", { nonce: 4 }], + ]); + }); + + it("Gracefully handles timeout", async () => { + let [updater, queue] = setup(); + + // This update takes 50ms - too long! + let willTimeout = queue.executeOtherAsync(otherUpdate(50, 2)); + // Timeout + await delay(5); + // Assume (wrongly) it's ok to make another update. Same nonce. + let attemptToConflict = queue.executeOtherAsync(otherUpdate(5, 2)); + + // We can await these in any order. The original update succeeds, + // the conflicting nonce fails due to validation.. + expect((await willTimeout).isError).to.be.false; + expect((await attemptToConflict).isError).to.be.true; + + // Shows only one succeeded because if not we would see two updates with + // the same nonce here. + expect(updater.state).to.be.deep.equal([ + ["other", { nonce: 2 }], + ]); + }); +}); diff --git a/modules/protocol/src/testing/sync.spec.ts b/modules/protocol/src/testing/sync.spec.ts index 332e3fe06..3e189bbf6 100644 --- a/modules/protocol/src/testing/sync.spec.ts +++ b/modules/protocol/src/testing/sync.spec.ts @@ -5,7 +5,6 @@ import { createTestChannelUpdateWithSigners, createTestChannelStateWithSigners, createTestFullHashlockTransferState, - getRandomBytes32, createTestUpdateParams, mkAddress, mkSig, @@ -22,7 +21,6 @@ import { UpdateParams, FullChannelState, FullTransferState, - ChainError, IVectorChainReader, } from "@connext/vector-types"; import { AddressZero } from "@ethersproject/constants"; @@ -31,7 +29,7 @@ import Sinon from "sinon"; import { VectorChainReader } from "@connext/vector-contracts"; // Import as full module for easy sinon function mocking -import { OutboundChannelUpdateError, InboundChannelUpdateError } from "../errors"; +import { QueuedUpdateError } from "../errors"; import * as vectorUtils from "../utils"; import * as vectorValidation from "../validate"; import { inbound, outbound } from "../sync"; @@ -40,9 +38,7 @@ import { env } from "./env"; describe("inbound", () => { const chainProviders = env.chainProviders; - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const [chainIdStr, providerUrl] = Object.entries(chainProviders)[0] as string[]; - const inbox = getRandomBytes32(); + const [_, providerUrl] = Object.entries(chainProviders)[0] as string[]; const logger = pino().child({ testName: "inbound", }); @@ -54,8 +50,6 @@ describe("inbound", () => { }; let signers: ChannelSigner[]; - let store: Sinon.SinonStubbedInstance; - let messaging: Sinon.SinonStubbedInstance; let chainService: Sinon.SinonStubbedInstance; let validationStub: Sinon.SinonStub; @@ -64,8 +58,6 @@ describe("inbound", () => { signers = Array(2) .fill(0) .map(() => getRandomChannelSigner(providerUrl)); - store = Sinon.createStubInstance(MemoryStoreService); - messaging = Sinon.createStubInstance(MemoryMessagingService); chainService = Sinon.createStubInstance(VectorChainReader); // Set the validation stub @@ -77,8 +69,9 @@ describe("inbound", () => { }); it("should return an error if the update does not advance state", async () => { - // Set the store mock - store.getChannelState.resolves({ nonce: 1, latestUpdate: {} as any } as any); + // Set the stored values + const activeTransfers = []; + const channel = createTestChannelStateWithSigners(signers, UpdateType.setup, { nonce: 1 }); // Generate an update at nonce = 1 const update = createTestChannelUpdateWithSigners(signers, UpdateType.setup, { nonce: 1 }); @@ -86,119 +79,42 @@ describe("inbound", () => { const result = await inbound( update, {} as any, - inbox, + activeTransfers, + channel, chainService as IVectorChainReader, - store, - messaging, externalValidation, signers[1], logger, ); expect(result.isError).to.be.true; const error = result.getError()!; - expect(error.message).to.be.eq(InboundChannelUpdateError.reasons.StaleUpdate); - - // Verify calls - expect(messaging.respondWithProtocolError.callCount).to.be.eq(1); - expect(store.saveChannelState.callCount).to.be.eq(0); - }); - - it("should fail if you are 3+ states behind the update", async () => { - // Generate the update - const prevUpdate: ChannelUpdate = createTestChannelUpdateWithSigners( - signers, - UpdateType.setup, - { - nonce: 1, - }, - ); - - const update: ChannelUpdate = createTestChannelUpdateWithSigners( - signers, - UpdateType.setup, - { - nonce: 5, - }, - ); - - const result = await inbound( - update, - prevUpdate, - inbox, - chainService as IVectorChainReader, - store, - messaging, - externalValidation, - signers[1], - logger, - ); - - expect(result.isError).to.be.true; - const error = result.getError()!; - expect(error.message).to.be.eq(InboundChannelUpdateError.reasons.RestoreNeeded); - // Make sure the calls were correctly performed - expect(validationStub.callCount).to.be.eq(0); - expect(store.saveChannelState.callCount).to.be.eq(0); - expect(messaging.respondToProtocolMessage.callCount).to.be.eq(0); + expect(error.message).to.be.eq(QueuedUpdateError.reasons.StaleUpdate); }); it("should fail if validating the update fails", async () => { + // Set the stored values + const activeTransfers = []; + const channel = createTestChannelStateWithSigners(signers, UpdateType.setup, { nonce: 1 }); + // Generate the update const update: ChannelUpdate = createTestChannelUpdateWithSigners( signers, UpdateType.deposit, { - nonce: 1, + nonce: 2, }, ); // Set the validation stub validationStub.resolves( - Result.fail( - new InboundChannelUpdateError(InboundChannelUpdateError.reasons.ExternalValidationFailed, update, {} as any), - ), + Result.fail(new QueuedUpdateError(QueuedUpdateError.reasons.ExternalValidationFailed, update, {} as any)), ); const result = await inbound( update, - update, - inbox, - chainService as IVectorChainReader, - store, - messaging, - externalValidation, - signers[1], - logger, - ); - - expect(result.isError).to.be.true; - const error = result.getError()!; - expect(error.message).to.be.eq(InboundChannelUpdateError.reasons.ExternalValidationFailed); - // Make sure the calls were correctly performed - expect(validationStub.callCount).to.be.eq(1); - expect(store.saveChannelState.callCount).to.be.eq(0); - expect(messaging.respondToProtocolMessage.callCount).to.be.eq(0); - }); - - it("should fail if saving the data fails", async () => { - // Generate the update - store.saveChannelState.rejects(); - - const update: ChannelUpdate = createTestChannelUpdateWithSigners( - signers, - UpdateType.setup, - { - nonce: 1, - }, - ); - // Set the validation stub - validationStub.resolves(Result.ok({ updatedChannel: {} as any })); - const result = await inbound( - update, - update, - inbox, + channel.latestUpdate, + activeTransfers, + channel, chainService as IVectorChainReader, - store, - messaging, externalValidation, signers[1], logger, @@ -206,16 +122,18 @@ describe("inbound", () => { expect(result.isError).to.be.true; const error = result.getError()!; - expect(error.message).to.be.eq(InboundChannelUpdateError.reasons.SaveChannelFailed); + expect(error.message).to.be.eq(QueuedUpdateError.reasons.ExternalValidationFailed); // Make sure the calls were correctly performed expect(validationStub.callCount).to.be.eq(1); - expect(store.saveChannelState.callCount).to.be.eq(1); - expect(messaging.respondToProtocolMessage.callCount).to.be.eq(0); }); - it("should update if stored state is in sync", async () => { - // Set the store mock - store.getChannelState.resolves({ nonce: 1, latestUpdate: {} as any } as any); + it("should update if state is in sync", async () => { + // Set the stored values + const activeTransfers = []; + const channel = createTestChannelStateWithSigners(signers, UpdateType.setup, { + nonce: 1, + latestUpdate: { nonce: 1 }, + }); // Set the validation stub validationStub.resolves(Result.ok({ updatedChannel: { nonce: 3 } as any })); @@ -228,11 +146,10 @@ describe("inbound", () => { // Call `inbound` const result = await inbound( update, - update, - inbox, + channel.latestUpdate, + activeTransfers, + channel, chainService as IVectorChainReader, - store, - messaging, externalValidation, signers[1], logger, @@ -240,46 +157,61 @@ describe("inbound", () => { expect(result.getError()).to.be.undefined; // Verify callstack - expect(messaging.respondToProtocolMessage.callCount).to.be.eq(1); - expect(messaging.respondWithProtocolError.callCount).to.be.eq(0); - expect(store.saveChannelState.callCount).to.be.eq(1); expect(validationStub.callCount).to.be.eq(1); }); - describe("IFF the update.nonce is ahead by 2, then the update recipient should try to sync", () => { + describe("If our previous update is behind, it should try to sync", () => { it("should fail if there is no missed update", async () => { - // Set the store mock - store.getChannelState.resolves({ nonce: 1 } as any); + // Set the stored values + const activeTransfers = []; + const channel = createTestChannelStateWithSigners(signers, UpdateType.setup, { nonce: 1 }); // Create the received update - const update = createTestChannelUpdateWithSigners(signers, UpdateType.deposit, { nonce: 3 }); + const update = createTestChannelUpdateWithSigners(signers, UpdateType.deposit, { nonce: 4 }); // Create the update to sync const result = await inbound( update, undefined as any, - inbox, + activeTransfers, + channel, chainService as IVectorChainReader, - store, - messaging, externalValidation, signers[1], logger, ); - expect(result.getError()?.message).to.be.eq(InboundChannelUpdateError.reasons.StaleChannel); + expect(result.getError()?.message).to.be.eq(QueuedUpdateError.reasons.StaleUpdate); + }); - // Verify nothing was saved and error properly sent - expect(store.saveChannelState.callCount).to.be.eq(0); - expect(messaging.respondToProtocolMessage.callCount).to.be.eq(0); - expect(messaging.respondWithProtocolError.callCount).to.be.eq(1); + it("should fail if the update to sync is a setup update", async () => { + // Set the stored values + const activeTransfers = []; + const channel = createTestChannelStateWithSigners(signers, UpdateType.setup, { nonce: 1 }); + + // Create the received update + const update = createTestChannelUpdateWithSigners(signers, UpdateType.deposit, { nonce: 4 }); + + // Create the update to sync + const result = await inbound( + update, + channel.latestUpdate, + activeTransfers, + undefined, + chainService as IVectorChainReader, + externalValidation, + signers[1], + logger, + ); + expect(result.getError()?.message).to.be.eq(QueuedUpdateError.reasons.CannotSyncSetup); }); it("should fail if the missed update is not double signed", async () => { - // Set the store mock - store.getChannelState.resolves({ nonce: 1 } as any); + // Set the stored values + const activeTransfers = []; + const channel = createTestChannelStateWithSigners(signers, UpdateType.setup, { nonce: 1 }); // Create the received update - const update = createTestChannelUpdateWithSigners(signers, UpdateType.deposit, { nonce: 3 }); + const update = createTestChannelUpdateWithSigners(signers, UpdateType.deposit, { nonce: 4 }); // Create previous update const toSync = createTestChannelUpdateWithSigners(signers, UpdateType.deposit, { @@ -291,115 +223,107 @@ describe("inbound", () => { const result = await inbound( update, toSync, - inbox, + activeTransfers, + channel, chainService as IVectorChainReader, - store, - messaging, externalValidation, signers[1], logger, ); - expect(result.getError()?.message).to.be.eq(InboundChannelUpdateError.reasons.SyncFailure); - expect(result.getError()?.context.syncError).to.be.eq("Cannot sync single signed state"); - - // Verify nothing was saved and error properly sent - expect(store.saveChannelState.callCount).to.be.eq(0); - expect(messaging.respondToProtocolMessage.callCount).to.be.eq(0); - expect(messaging.respondWithProtocolError.callCount).to.be.eq(1); + expect(result.getError()?.message).to.be.eq(QueuedUpdateError.reasons.SyncSingleSigned); }); - it("should fail if the missed update fails validation", async () => { - // Set the store mock - store.getChannelState.resolves({ nonce: 1 } as any); + it("should fail if the update to sync is not the next update (i.e. off by more than 1 transition)", async () => { + // Set the stored values + const activeTransfers = []; + const channel = createTestChannelStateWithSigners(signers, UpdateType.setup, { nonce: 1 }); // Create the received update - const update = createTestChannelUpdateWithSigners(signers, UpdateType.deposit, { nonce: 3 }); + const update = createTestChannelUpdateWithSigners(signers, UpdateType.deposit, { nonce: 4 }); // Create previous update const toSync = createTestChannelUpdateWithSigners(signers, UpdateType.deposit, { - nonce: 2, + nonce: 8, }); - // Set validation mock - validationStub.resolves(Result.fail(new Error("fail"))); - // Create the update to sync const result = await inbound( update, toSync, - inbox, + activeTransfers, + channel, chainService as IVectorChainReader, - store, - messaging, externalValidation, signers[1], logger, ); - expect(result.getError()!.message).to.be.eq(InboundChannelUpdateError.reasons.SyncFailure); - expect(result.getError()!.context.syncError).to.be.eq("fail"); - - // Verify nothing was saved and error properly sent - expect(store.saveChannelState.callCount).to.be.eq(0); - expect(messaging.respondToProtocolMessage.callCount).to.be.eq(0); - expect(messaging.respondWithProtocolError.callCount).to.be.eq(1); + expect(result.getError()?.message).to.be.eq(QueuedUpdateError.reasons.RestoreNeeded); }); - it("should fail if fails to save the synced channel", async () => { - // Set the store mocks - store.getChannelState.resolves({ nonce: 1 } as any); - store.saveChannelState.rejects(new Error("fail")); + it("should fail if the missed update fails validation", async () => { + // Set the stored values + const activeTransfers = []; + const channel = createTestChannelStateWithSigners(signers, UpdateType.setup, { nonce: 1 }); // Create the received update const update = createTestChannelUpdateWithSigners(signers, UpdateType.deposit, { nonce: 3 }); // Create previous update const toSync = createTestChannelUpdateWithSigners(signers, UpdateType.deposit, { - nonce: 2, + nonce: vectorUtils.getNextNonceForUpdate(1, update.fromIdentifier === channel.aliceIdentifier), }); // Set validation mock - validationStub.resolves(Result.ok({ nonce: 2 } as any)); + validationStub.resolves(Result.fail(new Error("fail"))); // Create the update to sync const result = await inbound( update, toSync, - inbox, + activeTransfers, + channel, chainService as IVectorChainReader, - store, - messaging, externalValidation, signers[1], logger, ); - expect(result.getError()!.message).to.be.eq(InboundChannelUpdateError.reasons.SyncFailure); - expect(result.getError()?.context.syncError).to.be.eq("fail"); - - // Verify nothing was saved and error properly sent - expect(store.saveChannelState.callCount).to.be.eq(1); - expect(messaging.respondToProtocolMessage.callCount).to.be.eq(0); - expect(messaging.respondWithProtocolError.callCount).to.be.eq(1); + expect(result.getError()!.message).to.be.eq("fail"); }); describe("should properly sync channel and apply update", async () => { // Declare params const runTest = async (proposedType: UpdateType, typeToSync: UpdateType) => { - // Set store mocks - store.getChannelState.resolves({ nonce: 1, latestUpdate: {} as any } as any); + // Set the stored values + const activeTransfers = []; + const channel = createTestChannelStateWithSigners(signers, UpdateType.setup, { + nonce: 1, + latestUpdate: {} as any, + }); // Set validation mocks - const proposed = createTestChannelUpdateWithSigners(signers, proposedType, { nonce: 3 }); - const toSync = createTestChannelUpdateWithSigners(signers, typeToSync, { nonce: 2 }); - validationStub.onFirstCall().resolves(Result.ok({ updatedChannel: { nonce: 2, latestUpdate: toSync } })); - validationStub.onSecondCall().resolves(Result.ok({ updatedChannel: { nonce: 3, latestUpdate: proposed } })); + const toSyncNonce = vectorUtils.getNextNonceForUpdate(channel.nonce, true); + const proposedNonce = vectorUtils.getNextNonceForUpdate(toSyncNonce, true); + const proposed = createTestChannelUpdateWithSigners(signers, proposedType, { + nonce: proposedNonce, + fromIdentifier: channel.aliceIdentifier, + }); + const toSync = createTestChannelUpdateWithSigners(signers, typeToSync, { + nonce: toSyncNonce, + fromIdentifier: channel.aliceIdentifier, + }); + validationStub + .onFirstCall() + .resolves(Result.ok({ updatedChannel: { nonce: toSyncNonce, latestUpdate: toSync } })); + validationStub + .onSecondCall() + .resolves(Result.ok({ updatedChannel: { nonce: proposedNonce, latestUpdate: proposed } })); const result = await inbound( proposed, toSync, - inbox, + activeTransfers, + channel, chainService as IVectorChainReader, - store, - messaging, externalValidation, signers[1], logger, @@ -407,12 +331,9 @@ describe("inbound", () => { expect(result.getError()).to.be.undefined; // Verify callstack - expect(messaging.respondToProtocolMessage.callCount).to.be.eq(1); - expect(messaging.respondWithProtocolError.callCount).to.be.eq(0); - expect(store.saveChannelState.callCount).to.be.eq(2); expect(validationStub.callCount).to.be.eq(2); - expect(validationStub.firstCall.args[3].nonce).to.be.eq(2); - expect(validationStub.secondCall.args[3].nonce).to.be.eq(3); + expect(validationStub.firstCall.args[3].nonce).to.be.eq(toSyncNonce); + expect(validationStub.secondCall.args[3].nonce).to.be.eq(proposedNonce); }; for (const proposalType of Object.keys(UpdateType)) { @@ -434,36 +355,44 @@ describe("inbound", () => { }); it("IFF update is invalid and channel is out of sync, should fail on retry, but sync properly", async () => { - // Set previous state - store.getChannelState.resolves(createTestChannelStateWithSigners(signers, UpdateType.setup, { nonce: 1 })); + // Set the stored values + const activeTransfers = []; + const channel = createTestChannelStateWithSigners(signers, UpdateType.setup, { + nonce: 1, + latestUpdate: {} as any, + }); + + const toSyncNonce = vectorUtils.getNextNonceForUpdate(channel.nonce, true); + const proposedNonce = vectorUtils.getNextNonceForUpdate(toSyncNonce, true); // Set update to sync const prevUpdate = createTestChannelUpdateWithSigners(signers, UpdateType.deposit, { - nonce: 2, + nonce: toSyncNonce, + fromIdentifier: channel.aliceIdentifier, }); - validationStub.onFirstCall().resolves(Result.ok({ updatedChannel: { nonce: 3, latestUpdate: {} as any } })); + validationStub + .onFirstCall() + .resolves(Result.ok({ updatedChannel: { nonce: toSyncNonce, latestUpdate: {} as any } })); const update: ChannelUpdate = createTestChannelUpdateWithSigners( signers, UpdateType.deposit, { - nonce: 3, + nonce: proposedNonce, + fromIdentifier: channel.aliceIdentifier, }, ); validationStub .onSecondCall() .resolves( - Result.fail( - new InboundChannelUpdateError(InboundChannelUpdateError.reasons.ExternalValidationFailed, update, {} as any), - ), + Result.fail(new QueuedUpdateError(QueuedUpdateError.reasons.ExternalValidationFailed, update, {} as any)), ); const result = await inbound( update, prevUpdate, - inbox, + activeTransfers, + channel, chainService as IVectorChainReader, - store, - messaging, externalValidation, signers[1], logger, @@ -471,16 +400,17 @@ describe("inbound", () => { expect(result.isError).to.be.true; const error = result.getError()!; - expect(error.message).to.be.eq(InboundChannelUpdateError.reasons.ExternalValidationFailed); + expect(error.message).to.be.eq(QueuedUpdateError.reasons.ExternalValidationFailed); expect(validationStub.callCount).to.be.eq(2); - expect(validationStub.firstCall.args[3].nonce).to.be.eq(2); - expect(validationStub.secondCall.args[3].nonce).to.be.eq(3); - // Make sure the calls were correctly performed - expect(store.saveChannelState.callCount).to.be.eq(1); - expect(messaging.respondToProtocolMessage.callCount).to.be.eq(0); + expect(validationStub.firstCall.args[3].nonce).to.be.eq(toSyncNonce); + expect(validationStub.secondCall.args[3].nonce).to.be.eq(proposedNonce); }); it("should work if there is no channel state stored and you are receiving a setup update", async () => { + // Set the stored values + const activeTransfers = []; + const channel = undefined; + // Generate the update const update: ChannelUpdate = createTestChannelUpdateWithSigners( signers, @@ -494,20 +424,14 @@ describe("inbound", () => { const result = await inbound( update, update, - inbox, + activeTransfers, + channel, chainService as IVectorChainReader, - store, - messaging, externalValidation, signers[1], logger, ); - expect(result.getError()).to.be.undefined; - - // Make sure the calls were correctly performed - expect(validationStub.callCount).to.be.eq(1); - expect(messaging.respondToProtocolMessage.callCount).to.be.eq(1); - expect(store.saveChannelState.callCount).to.be.eq(1); + expect(result.getError()?.message).to.be.eq(QueuedUpdateError.reasons.CannotSyncSetup); }); }); @@ -533,6 +457,7 @@ describe("outbound", () => { let validateParamsAndApplyStub: Sinon.SinonStub; // called during sync let validateAndApplyInboundStub: Sinon.SinonStub; + let validateUpdateIdSignatureStub: Sinon.SinonStub; beforeEach(async () => { signers = Array(2) @@ -550,6 +475,9 @@ describe("outbound", () => { // Stub out all signature validation validateUpdateSignatureStub = Sinon.stub(vectorUtils, "validateChannelSignatures").resolves(Result.ok(undefined)); + validateUpdateIdSignatureStub = Sinon.stub(vectorUtils, "validateChannelUpdateIdSignature").resolves( + Result.ok(undefined), + ); }); afterEach(() => { @@ -557,44 +485,22 @@ describe("outbound", () => { Sinon.restore(); }); - describe("should fail if .getChannelState / .getActiveTransfers / .getTransferState fails", () => { - const methods = ["getChannelState", "getActiveTransfers"]; - - for (const method of methods) { - it(method, async () => { - // Set store stub - store[method].rejects(new Error("fail")); - - // Make outbound call - const result = await outbound( - createTestUpdateParams(UpdateType.resolve), - store, - chainService as IVectorChainReader, - messaging, - externalValidation, - signers[0], - log, - ); - - // Assert error - expect(result.isError).to.be.eq(true); - const error = result.getError()!; - expect(error.message).to.be.eq(OutboundChannelUpdateError.reasons.StoreFailure); - expect(error.context.storeError).to.be.eq(`${method} failed: fail`); - }); - } - }); - it("should fail if it fails to validate and apply the update", async () => { + // Generate stored info + const activeTransfers = []; + const previousState = createTestChannelStateWithSigners(signers, UpdateType.deposit); + + // Generate params const params = createTestUpdateParams(UpdateType.deposit, { channelAddress: "0xfail" }); // Stub the validation function - const error = new OutboundChannelUpdateError(OutboundChannelUpdateError.reasons.InvalidParams, params); + const error = new QueuedUpdateError(QueuedUpdateError.reasons.InvalidParams, params); validateParamsAndApplyStub.resolves(Result.fail(error)); const res = await outbound( params, - store, + activeTransfers, + previousState, chainService as IVectorChainReader, messaging, externalValidation, @@ -605,13 +511,17 @@ describe("outbound", () => { }); it("should fail if it counterparty update fails for some reason other than update being out of date", async () => { + // Generate stored info + const activeTransfers = []; + const previousState = createTestChannelStateWithSigners(signers, UpdateType.deposit, { channelAddress }); + // Create a setup update const params = createTestUpdateParams(UpdateType.setup, { channelAddress, details: { counterpartyIdentifier: signers[1].publicIdentifier }, }); // Create a messaging service stub - const counterpartyError = new InboundChannelUpdateError(InboundChannelUpdateError.reasons.StoreFailure, {} as any); + const counterpartyError = new QueuedUpdateError(QueuedUpdateError.reasons.StoreFailure, {} as any); messaging.sendProtocolMessage.resolves(Result.fail(counterpartyError)); // Stub the generation function @@ -627,7 +537,8 @@ describe("outbound", () => { // Call the outbound function const res = await outbound( params, - store, + activeTransfers, + previousState, chainService as IVectorChainReader, messaging, externalValidation, @@ -637,7 +548,7 @@ describe("outbound", () => { // Verify the error is returned as an outbound error const error = res.getError(); - expect(error?.message).to.be.eq(OutboundChannelUpdateError.reasons.CounterpartyFailure); + expect(error?.message).to.be.eq(QueuedUpdateError.reasons.CounterpartyFailure); expect(error?.context.counterpartyError.message).to.be.eq(counterpartyError.message); expect(error?.context.counterpartyError.context).to.be.ok; @@ -646,6 +557,10 @@ describe("outbound", () => { }); it("should fail if it the signature validation fails", async () => { + // Generate stored info + const activeTransfers = []; + const previousState = createTestChannelStateWithSigners(signers, UpdateType.deposit, { channelAddress }); + // Stub generation function validateParamsAndApplyStub.resolves( Result.ok({ @@ -665,53 +580,22 @@ describe("outbound", () => { // Make outbound call const res = await outbound( createTestUpdateParams(UpdateType.deposit), - store, + activeTransfers, + previousState, chainService as IVectorChainReader, messaging, externalValidation, signers[0], log, ); - expect(res.getError()!.message).to.be.eq(OutboundChannelUpdateError.reasons.BadSignatures); - }); - - it("should fail if the channel is not saved to store", async () => { - // Stub save method to fail - store.saveChannelState.rejects("Failed to save channel"); - - const params = createTestUpdateParams(UpdateType.deposit, { - channelAddress, - }); - - // Stub the generation results - validateParamsAndApplyStub.resolves( - Result.ok({ - update: createTestChannelUpdateWithSigners(signers, UpdateType.deposit), - updatedTransfer: undefined, - updatedActiveTransfers: undefined, - updatedChannel: createTestChannelStateWithSigners(signers, UpdateType.deposit), - }), - ); - - // Set the messaging mocks to return the proper update from the counterparty - messaging.sendProtocolMessage.onFirstCall().resolves(Result.ok({ update: {}, previousUpdate: {} } as any)); - - const result = await outbound( - params, - store, - chainService as IVectorChainReader, - messaging, - externalValidation, - signers[0], - log, - ); - - expect(result.isError).to.be.true; - const error = result.getError()!; - expect(error.message).to.be.eq(OutboundChannelUpdateError.reasons.SaveChannelFailed); + expect(res.getError()!.message).to.be.eq(QueuedUpdateError.reasons.BadSignatures); }); it("should successfully initiate an update if channels are in sync", async () => { + // Generate stored info + const activeTransfers = []; + const previousState = createTestChannelStateWithSigners(signers, UpdateType.deposit, { channelAddress, nonce: 1 }); + // Create the update (a user deposit on a setup channel) const assetId = AddressZero; const params: UpdateParams = createTestUpdateParams(UpdateType.deposit, { @@ -719,18 +603,13 @@ describe("outbound", () => { details: { assetId }, }); - // Create the channel and store mocks for the user - // channel at nonce 1, proposes nonce 2, syncs nonce 2 from counterparty - // then proposes nonce 3 - store.getChannelState.resolves(createTestChannelStateWithSigners(signers, UpdateType.setup, { nonce: 2 })); - // Stub the generation results validateParamsAndApplyStub.onFirstCall().resolves( Result.ok({ update: createTestChannelUpdateWithSigners(signers, UpdateType.deposit), updatedTransfer: undefined, updatedActiveTransfers: undefined, - updatedChannel: createTestChannelStateWithSigners(signers, UpdateType.deposit, { nonce: 3 }), + updatedChannel: { ...previousState, nonce: 4 }, }), ); @@ -742,7 +621,8 @@ describe("outbound", () => { // Call the outbound function const res = await outbound( params, - store, + activeTransfers, + previousState, chainService as IVectorChainReader, messaging, externalValidation, @@ -752,17 +632,23 @@ describe("outbound", () => { // Verify return values expect(res.getError()).to.be.undefined; - expect(res.getValue().updatedChannel).to.containSubset({ nonce: 3 }); + expect(res.getValue().updatedChannel).to.containSubset({ nonce: 4 }); // Verify message only sent once by initiator w/update to sync expect(messaging.sendProtocolMessage.callCount).to.be.eq(1); // Verify sync happened expect(validateParamsAndApplyStub.callCount).to.be.eq(1); - expect(store.saveChannelState.callCount).to.be.eq(1); }); describe("counterparty returned a StaleUpdate error, indicating the channel should try to sync (hitting `syncStateAndRecreateUpdate`)", () => { it("should fail to sync setup update", async () => { + // Generate stored info + const activeTransfers = []; + const previousState = createTestChannelStateWithSigners(signers, UpdateType.deposit, { + channelAddress, + nonce: 1, + }); + const proposedParams = createTestUpdateParams(UpdateType.deposit); // Set generation stub @@ -774,19 +660,16 @@ describe("outbound", () => { ); // Stub counterparty return + const toSync = createTestChannelStateWithSigners(signers, UpdateType.setup); messaging.sendProtocolMessage.resolves( - Result.fail( - new InboundChannelUpdateError( - InboundChannelUpdateError.reasons.StaleUpdate, - createTestChannelUpdateWithSigners(signers, UpdateType.setup), - ), - ), + Result.fail(new QueuedUpdateError(QueuedUpdateError.reasons.StaleUpdate, toSync.latestUpdate, toSync)), ); // Send request const result = await outbound( proposedParams, - store, + activeTransfers, + previousState, chainService as IVectorChainReader, messaging, externalValidation, @@ -795,14 +678,19 @@ describe("outbound", () => { ); // Verify error - expect(result.getError()?.message).to.be.eq(OutboundChannelUpdateError.reasons.CannotSyncSetup); + expect(result.getError()?.message).to.be.eq(QueuedUpdateError.reasons.CannotSyncSetup); // Verify update was not retried expect(messaging.sendProtocolMessage.callCount).to.be.eq(1); - // Verify channel was not updated - expect(store.saveChannelState.callCount).to.be.eq(0); }); it("should fail if update to sync is single signed", async () => { + // Generate stored info + const activeTransfers = []; + const previousState = createTestChannelStateWithSigners(signers, UpdateType.deposit, { + channelAddress, + nonce: 1, + }); + const proposedParams = createTestUpdateParams(UpdateType.deposit); // Set generation stub @@ -814,22 +702,21 @@ describe("outbound", () => { ); // Stub counterparty return + const toSync = createTestChannelUpdateWithSigners(signers, UpdateType.deposit, { + aliceSignature: undefined, + bobSignature: mkSig(), + }); messaging.sendProtocolMessage.resolves( Result.fail( - new InboundChannelUpdateError( - InboundChannelUpdateError.reasons.StaleUpdate, - createTestChannelUpdateWithSigners(signers, UpdateType.deposit, { - aliceSignature: undefined, - bobSignature: mkSig(), - }), - ), + new QueuedUpdateError(QueuedUpdateError.reasons.StaleUpdate, toSync, { latestUpdate: toSync } as any), ), ); // Send request const result = await outbound( proposedParams, - store, + activeTransfers, + previousState, chainService as IVectorChainReader, messaging, externalValidation, @@ -838,17 +725,19 @@ describe("outbound", () => { ); // Verify error - expect(result.getError()?.message).to.be.eq(OutboundChannelUpdateError.reasons.SyncFailure); - expect(result.getError()?.context.syncError).to.be.eq("Cannot sync single signed state"); + expect(result.getError()?.message).to.be.eq(QueuedUpdateError.reasons.SyncSingleSigned); // Verify update was not retried expect(messaging.sendProtocolMessage.callCount).to.be.eq(1); - // Verify channel was not updated - expect(store.saveChannelState.callCount).to.be.eq(0); }); it("should fail if it fails to apply the inbound update", async () => { // Set store mocks - store.getChannelState.resolves(createTestChannelStateWithSigners(signers, UpdateType.deposit, { nonce: 2 })); + // Generate stored info + const activeTransfers = []; + const previousState = createTestChannelStateWithSigners(signers, UpdateType.deposit, { + channelAddress, + nonce: 1, + }); // Set generation mock validateParamsAndApplyStub.resolves( @@ -859,14 +748,12 @@ describe("outbound", () => { ); // Stub counterparty return + const toSync = createTestChannelUpdateWithSigners(signers, UpdateType.deposit, { + nonce: 4, + }); messaging.sendProtocolMessage.resolves( Result.fail( - new InboundChannelUpdateError( - InboundChannelUpdateError.reasons.StaleUpdate, - createTestChannelUpdateWithSigners(signers, UpdateType.deposit, { - nonce: 3, - }), - ), + new QueuedUpdateError(QueuedUpdateError.reasons.StaleUpdate, toSync, { latestUpdate: toSync } as any), ), ); @@ -876,7 +763,8 @@ describe("outbound", () => { // Send request const result = await outbound( createTestUpdateParams(UpdateType.deposit), - store, + activeTransfers, + previousState, chainService as IVectorChainReader, messaging, externalValidation, @@ -885,109 +773,9 @@ describe("outbound", () => { ); // Verify error - expect(result.getError()?.message).to.be.eq(OutboundChannelUpdateError.reasons.SyncFailure); - expect(result.getError()?.context.syncError).to.be.eq("fail"); + expect(result.getError()?.message).to.be.eq("fail"); // Verify update was not retried expect(messaging.sendProtocolMessage.callCount).to.be.eq(1); - // Verify channel was not updated - expect(store.saveChannelState.callCount).to.be.eq(0); - }); - - it("should fail if it cannot save synced channel to store", async () => { - // Set the apply/update return value - const applyRet = { - update: createTestChannelUpdate(UpdateType.deposit), - updatedChannel: createTestChannelUpdateWithSigners(signers, UpdateType.deposit, { nonce: 3 }), - }; - - // Set store mocks - store.getChannelState.resolves(createTestChannelStateWithSigners(signers, UpdateType.deposit, { nonce: 2 })); - store.saveChannelState.rejects("fail"); - - // Set generation mock - validateParamsAndApplyStub.resolves(Result.ok(applyRet)); - - // Stub counterparty return - messaging.sendProtocolMessage.resolves( - Result.fail( - new InboundChannelUpdateError( - InboundChannelUpdateError.reasons.StaleUpdate, - createTestChannelUpdateWithSigners(signers, UpdateType.deposit, { - nonce: 3, - }), - ), - ), - ); - - // Stub the apply function - validateAndApplyInboundStub.resolves(Result.ok(applyRet)); - - // Send request - const result = await outbound( - createTestUpdateParams(UpdateType.deposit), - store, - chainService as IVectorChainReader, - messaging, - externalValidation, - signers[0], - log, - ); - - // Verify error - expect(result.getError()?.message).to.be.eq(OutboundChannelUpdateError.reasons.SyncFailure); - // Verify update was not retried - expect(messaging.sendProtocolMessage.callCount).to.be.eq(1); - // Verify channel save was attempted - expect(store.saveChannelState.callCount).to.be.eq(1); - }); - - it("should fail if it cannot re-validate proposed parameters", async () => { - // Set the apply/update return value - const applyRet = { - update: createTestChannelUpdate(UpdateType.deposit), - updatedChannel: createTestChannelUpdateWithSigners(signers, UpdateType.deposit, { nonce: 3 }), - }; - - // Set store mocks - store.getChannelState.resolves(createTestChannelStateWithSigners(signers, UpdateType.deposit, { nonce: 2 })); - - // Set generation mock - validateParamsAndApplyStub.onFirstCall().resolves(Result.ok(applyRet)); - validateParamsAndApplyStub.onSecondCall().resolves(Result.fail(new ChainError("fail"))); - - // Stub counterparty return - messaging.sendProtocolMessage.resolves( - Result.fail( - new InboundChannelUpdateError( - InboundChannelUpdateError.reasons.StaleUpdate, - createTestChannelUpdateWithSigners(signers, UpdateType.deposit, { - nonce: 3, - }), - ), - ), - ); - - // Stub the sync function - validateAndApplyInboundStub.resolves(Result.ok(applyRet)); - - // Send request - const result = await outbound( - createTestUpdateParams(UpdateType.deposit), - store, - chainService as IVectorChainReader, - messaging, - externalValidation, - signers[0], - log, - ); - - // Verify error - expect(result.getError()?.message).to.be.eq(OutboundChannelUpdateError.reasons.RegenerateUpdateFailed); - expect(result.getError()?.context.regenerateUpdateError).to.be.eq("fail"); - // Verify update was not retried - expect(messaging.sendProtocolMessage.callCount).to.be.eq(1); - // Verify channel save was called - expect(store.saveChannelState.callCount).to.be.eq(1); }); // responder nonce n, proposed update nonce by initiator is at n too. @@ -998,11 +786,14 @@ describe("outbound", () => { let preSyncUpdatedState; let params; let preSyncUpdate; - let postSyncUpdate; // create a helper to create the proper counterparty error const createInboundError = (updateToSync: ChannelUpdate): any => { - return Result.fail(new InboundChannelUpdateError(InboundChannelUpdateError.reasons.StaleUpdate, updateToSync)); + return Result.fail( + new QueuedUpdateError(QueuedUpdateError.reasons.StaleUpdate, updateToSync, { + latestUpdate: updateToSync, + } as any), + ); }; // create a helper to create a post-sync state @@ -1021,29 +812,33 @@ describe("outbound", () => { }; // create a helper to establish mocks - const createTestEnv = (typeToSync: UpdateType): void => { + const createTestEnv = ( + typeToSync: UpdateType, + ): { activeTransfers: FullTransferState[]; previousState: FullChannelState; toSync: ChannelUpdate } => { // Create the missed update const toSync = createUpdateToSync(typeToSync); + // Generate stored info + const previousState = createTestChannelStateWithSigners(signers, UpdateType.deposit, { + channelAddress, + nonce: 1, + }); + // If it is resolve, make sure the store returns this in the // active transfers + the proper transfer state + let activeTransfers; if (typeToSync === UpdateType.resolve) { const transfer = createTestFullHashlockTransferState({ transferId: toSync.details.transferId }); - store.getActiveTransfers.resolves([transfer]); - store.getTransferState.resolves({ ...transfer, transferResolver: undefined }); + activeTransfers = [transfer]; chainService.resolve.resolves(Result.ok(transfer.balance)); } else { // otherwise, assume no other active transfers - store.getActiveTransfers.resolves([]); + activeTransfers = []; } // Set messaging mocks: // - first call should return an error - // - second call should return a final channel state messaging.sendProtocolMessage.onFirstCall().resolves(createInboundError(toSync)); - messaging.sendProtocolMessage - .onSecondCall() - .resolves(Result.ok({ update: postSyncUpdate, previousUpdate: toSync })); // Stub apply-sync results validateAndApplyInboundStub.resolves( @@ -1053,23 +848,18 @@ describe("outbound", () => { }), ); - // Stub the generation results post-sync - validateParamsAndApplyStub.onSecondCall().resolves( - Result.ok({ - update: postSyncUpdate, - updatedChannel: createUpdatedState(postSyncUpdate), - }), - ); + return { previousState, activeTransfers, toSync }; }; // create a helper to verify calling + code path const runTest = async (typeToSync: UpdateType): Promise => { - createTestEnv(typeToSync); + const { previousState, activeTransfers, toSync } = createTestEnv(typeToSync); // Call the outbound function const res = await outbound( params, - store, + activeTransfers, + previousState, chainService as IVectorChainReader, messaging, externalValidation, @@ -1077,28 +867,26 @@ describe("outbound", () => { log, ); - // Verify the update was successfully sent + retried + // Verify the update was successfully sent + synced expect(res.getError()).to.be.undefined; + expect(res.getValue().successfullyApplied).to.be.eq("synced"); expect(res.getValue().updatedChannel).to.be.containSubset({ - nonce: postSyncUpdate.nonce, - latestUpdate: postSyncUpdate, + nonce: toSync.nonce, + latestUpdate: toSync, }); - expect(messaging.sendProtocolMessage.callCount).to.be.eq(2); - expect(store.saveChannelState.callCount).to.be.eq(2); - expect(validateParamsAndApplyStub.callCount).to.be.eq(2); + expect(messaging.sendProtocolMessage.callCount).to.be.eq(1); + expect(validateParamsAndApplyStub.callCount).to.be.eq(1); expect(validateAndApplyInboundStub.callCount).to.be.eq(1); - expect(validateUpdateSignatureStub.callCount).to.be.eq(1); }; describe("initiator trying deposit", () => { beforeEach(() => { // Create the test params - preSyncState = createTestChannelStateWithSigners(signers, UpdateType.deposit, { nonce: 3 }); + preSyncState = createTestChannelStateWithSigners(signers, UpdateType.deposit, { nonce: 1 }); preSyncUpdatedState = createTestChannelStateWithSigners(signers, UpdateType.deposit, { nonce: 4 }); params = createTestUpdateParams(UpdateType.deposit); preSyncUpdate = createTestChannelUpdateWithSigners(signers, UpdateType.deposit, { nonce: 4 }); - postSyncUpdate = createTestChannelUpdateWithSigners(signers, UpdateType.deposit, { nonce: 5 }); // Set the stored state store.getChannelState.resolves(preSyncState); @@ -1136,7 +924,6 @@ describe("outbound", () => { params = createTestUpdateParams(UpdateType.create); preSyncUpdate = createTestChannelUpdateWithSigners(signers, UpdateType.create, { nonce: 4 }); - postSyncUpdate = createTestChannelUpdateWithSigners(signers, UpdateType.create, { nonce: 5 }); // Set the stored state store.getChannelState.resolves(preSyncState); @@ -1174,7 +961,6 @@ describe("outbound", () => { params = createTestUpdateParams(UpdateType.resolve); preSyncUpdate = createTestChannelUpdateWithSigners(signers, UpdateType.resolve, { nonce: 4 }); - postSyncUpdate = createTestChannelUpdateWithSigners(signers, UpdateType.resolve, { nonce: 5 }); // Set the stored state store.getChannelState.resolves(preSyncState); diff --git a/modules/protocol/src/testing/update.spec.ts b/modules/protocol/src/testing/update.spec.ts index 90638855e..126a39b6e 100644 --- a/modules/protocol/src/testing/update.spec.ts +++ b/modules/protocol/src/testing/update.spec.ts @@ -555,7 +555,10 @@ describe("generateAndApplyUpdate", () => { signer.publicIdentifier === aliceSigner.publicIdentifier ? bobSigner.publicIdentifier : aliceSigner.publicIdentifier, - nonce: (previousState?.nonce ?? 0) + 1, + nonce: vectorUtils.getNextNonceForUpdate( + previousState?.nonce ?? 0, + !!previousState ? previousState.aliceIdentifier === signer.publicIdentifier : true, + ), }; }; diff --git a/modules/protocol/src/testing/utils.spec.ts b/modules/protocol/src/testing/utils.spec.ts index 754af9cd8..d2e0bef5c 100644 --- a/modules/protocol/src/testing/utils.spec.ts +++ b/modules/protocol/src/testing/utils.spec.ts @@ -13,7 +13,7 @@ import { import Sinon from "sinon"; import { VectorChainReader } from "@connext/vector-contracts"; -import { generateSignedChannelCommitment, mergeAssetIds, reconcileDeposit } from "../utils"; +import { generateSignedChannelCommitment, mergeAssetIds, reconcileDeposit, getNextNonceForUpdate } from "../utils"; import { env } from "./env"; @@ -298,4 +298,112 @@ describe("utils", () => { }); } }); + + describe('get next nonce for update', () => { + const tests = [ + { + name: "0 alice => 1", + nonce: 0, + isAlice: true, + expect: 1, + }, + { + name: "0 bob => 2", + nonce: 0, + isAlice: false, + expect: 2, + }, + { + name: "1 alice => 4", + nonce: 1, + isAlice: true, + expect: 4, + }, + { + name: "1 bob => 2", + nonce: 1, + isAlice: false, + expect: 2, + }, + { + name: "2 alice => 4", + nonce: 2, + isAlice: true, + expect: 4, + }, + { + name: "2 bob => 3", + nonce: 2, + isAlice: false, + expect: 3, + }, + { + name: "3 alice => 4", + nonce: 3, + isAlice: true, + expect: 4, + }, + { + name: "3 bob => 6", + nonce: 3, + isAlice: false, + expect: 6, + }, + { + name: "4 alice => 5", + nonce: 4, + isAlice: true, + expect: 5, + }, + { + name: "4 bob => 6", + nonce: 4, + isAlice: false, + expect: 6, + }, + { + name: "5 alice => 8", + nonce: 5, + isAlice: true, + expect: 8, + }, + { + name: "5 bob => 6", + nonce: 5, + isAlice: false, + expect: 6 + }, + { + name: "6 alice => 8", + nonce: 6, + isAlice: true, + expect: 8, + }, + { + name: "6 bob => 7", + nonce: 6, + isAlice: false, + expect: 7, + }, + { + name: "7 alice => 8", + nonce: 7, + isAlice: true, + expect: 8, + }, + { + name: "7 bob => 10", + nonce: 7, + isAlice: false, + expect: 10, + }, + ]; + + for (const test of tests) { + it(test.name, () => { + const returned = getNextNonceForUpdate(test.nonce, test.isAlice); + expect(returned).to.be.eq(test.expect); + }); + } + }); }); diff --git a/modules/protocol/src/testing/utils/channel.ts b/modules/protocol/src/testing/utils/channel.ts index 14bb2316d..f42d4f75b 100644 --- a/modules/protocol/src/testing/utils/channel.ts +++ b/modules/protocol/src/testing/utils/channel.ts @@ -2,7 +2,6 @@ import { ChannelFactory, TestToken, VectorChannel, VectorChainReader } from "@co import { FullChannelState, IChannelSigner, - ILockService, IMessagingService, IVectorProtocol, IVectorStore, @@ -16,7 +15,6 @@ import { getTestLoggers, expect, MemoryStoreService, - MemoryLockService, MemoryMessagingService, getSignerAddressFromPublicIdentifier, } from "@connext/vector-utils"; @@ -33,7 +31,6 @@ import { fundAddress } from "./funding"; type VectorTestOverrides = { messagingService: IMessagingService; - lockService: ILockService; storeService: IVectorStore; signer: IChannelSigner; chainReader: IVectorChainReader; @@ -43,7 +40,6 @@ type VectorTestOverrides = { // NOTE: when operating with three counterparties, they must // all share a messaging service const sharedMessaging = new MemoryMessagingService(); -const sharedLock = new MemoryLockService(); const sharedChain = new VectorChainReader({ [chainId]: provider }, Pino()); export const createVectorInstances = async ( @@ -57,7 +53,6 @@ export const createVectorInstances = async ( .map(async (_, idx) => { const instanceOverrides = overrides[idx] || {}; const messagingService = shareServices ? sharedMessaging : new MemoryMessagingService(); - const lockService = shareServices ? sharedLock : new MemoryLockService(); const logger = instanceOverrides.logger ?? Pino(); const chainReader = shareServices ? sharedChain @@ -65,7 +60,6 @@ export const createVectorInstances = async ( const opts = { messagingService, - lockService, storeService: new MemoryStoreService(), signer: getRandomChannelSigner(provider), chainReader, diff --git a/modules/protocol/src/testing/validate.spec.ts b/modules/protocol/src/testing/validate.spec.ts index f791eddc4..123f4425f 100644 --- a/modules/protocol/src/testing/validate.spec.ts +++ b/modules/protocol/src/testing/validate.spec.ts @@ -11,7 +11,7 @@ import { mkAddress, createTestChannelStateWithSigners, getTransferId, - generateMerkleTreeData, + generateMerkleRoot, getRandomBytes32, } from "@connext/vector-utils"; import { @@ -35,7 +35,7 @@ import { import Sinon from "sinon"; import { AddressZero } from "@ethersproject/constants"; -import { OutboundChannelUpdateError, InboundChannelUpdateError, ValidationError } from "../errors"; +import { QueuedUpdateError, ValidationError } from "../errors"; import * as vectorUtils from "../utils"; import * as validation from "../validate"; import * as vectorUpdate from "../update"; @@ -49,6 +49,7 @@ describe("validateUpdateParams", () => { // Declare all mocks let chainReader: Sinon.SinonStubbedInstance; + let validateUpdateIdSignatureStub: Sinon.SinonStub; // Create helpers to create valid contexts const createValidSetupContext = () => { @@ -140,7 +141,7 @@ describe("validateUpdateParams", () => { balance: { to: [initiator.address, responder.address], amount: ["3", "0"] }, transferResolver: undefined, }); - const { root } = generateMerkleTreeData([transfer]); + const root = generateMerkleRoot([transfer]); const previousState = createTestChannelStateWithSigners([initiator, responder], UpdateType.deposit, { channelAddress, nonce, @@ -198,6 +199,10 @@ describe("validateUpdateParams", () => { chainReader = Sinon.createStubInstance(VectorChainReader); chainReader.getChannelAddress.resolves(Result.ok(channelAddress)); chainReader.create.resolves(Result.ok(true)); + + validateUpdateIdSignatureStub = Sinon.stub(vectorUtils, "validateChannelUpdateIdSignature").resolves( + Result.ok(undefined), + ); }); afterEach(() => { @@ -757,7 +762,7 @@ describe.skip("validateParamsAndApplyUpdate", () => { activeTransfers, signer.publicIdentifier, ); - expect(result.getError()?.message).to.be.eq(OutboundChannelUpdateError.reasons.OutboundValidationFailed); + expect(result.getError()?.message).to.be.eq(QueuedUpdateError.reasons.OutboundValidationFailed); expect(result.getError()?.context.params).to.be.deep.eq(params); expect(result.getError()?.context.state).to.be.deep.eq(previousState); expect(result.getError()?.context.error).to.be.eq("fail"); @@ -795,6 +800,7 @@ describe("validateAndApplyInboundUpdate", () => { let chainReader: Sinon.SinonStubbedInstance; let validateParamsAndApplyUpdateStub: Sinon.SinonStub; let validateChannelUpdateSignaturesStub: Sinon.SinonStub; + let validateUpdateIdSignatureStub: Sinon.SinonStub; let generateSignedChannelCommitmentStub: Sinon.SinonStub; let applyUpdateStub: Sinon.SinonStub; let externalValidationStub: { @@ -804,7 +810,7 @@ describe("validateAndApplyInboundUpdate", () => { // Create helper to run test const runErrorTest = async ( - errorMessage: Values, + errorMessage: Values, signer: ChannelSigner = signers[0], context: any = {}, ) => { @@ -834,6 +840,7 @@ describe("validateAndApplyInboundUpdate", () => { // Need for double signed and single signed validateChannelUpdateSignaturesStub.resolves(Result.ok(undefined)); + validateUpdateIdSignatureStub.resolves(Result.ok(undefined)); // Needed for double signed chainReader.resolve.resolves(Result.ok({ to: [updatedChannel.alice, updatedChannel.bob], amount: ["10", "2"] })); @@ -866,6 +873,9 @@ describe("validateAndApplyInboundUpdate", () => { validateChannelUpdateSignaturesStub = Sinon.stub(vectorUtils, "validateChannelSignatures").resolves( Result.ok(undefined), ); + validateUpdateIdSignatureStub = Sinon.stub(vectorUtils, "validateChannelUpdateIdSignature").resolves( + Result.ok(undefined), + ); generateSignedChannelCommitmentStub = Sinon.stub(vectorUtils, "generateSignedChannelCommitment"); applyUpdateStub = Sinon.stub(vectorUpdate, "applyUpdate"); externalValidationStub = { @@ -972,7 +982,7 @@ describe("validateAndApplyInboundUpdate", () => { for (const test of tests) { it(test.name, async () => { update = { ...valid, ...(test.overrides ?? {}) } as any; - await runErrorTest(InboundChannelUpdateError.reasons.MalformedUpdate, signers[0], { + await runErrorTest(QueuedUpdateError.reasons.MalformedUpdate, signers[0], { updateError: test.error, }); }); @@ -1037,7 +1047,7 @@ describe("validateAndApplyInboundUpdate", () => { ...test.overrides, }, }; - await runErrorTest(InboundChannelUpdateError.reasons.MalformedDetails, signers[0], { + await runErrorTest(QueuedUpdateError.reasons.MalformedDetails, signers[0], { detailsError: test.error, }); }); @@ -1077,7 +1087,7 @@ describe("validateAndApplyInboundUpdate", () => { ...test.overrides, }, }; - await runErrorTest(InboundChannelUpdateError.reasons.MalformedDetails, signers[0], { + await runErrorTest(QueuedUpdateError.reasons.MalformedDetails, signers[0], { detailsError: test.error, }); }); @@ -1147,16 +1157,6 @@ describe("validateAndApplyInboundUpdate", () => { overrides: { transferEncodings: "fail" }, error: "should be array", }, - { - name: "no merkleProofData", - overrides: { merkleProofData: undefined }, - error: "should have required property 'merkleProofData'", - }, - { - name: "malformed merkleProofData", - overrides: { merkleProofData: "fail" }, - error: "should be array", - }, { name: "no merkleRoot", overrides: { merkleRoot: undefined }, @@ -1182,7 +1182,7 @@ describe("validateAndApplyInboundUpdate", () => { ...test.overrides, }, }; - await runErrorTest(InboundChannelUpdateError.reasons.MalformedDetails, signers[0], { + await runErrorTest(QueuedUpdateError.reasons.MalformedDetails, signers[0], { detailsError: test.error, }); }); @@ -1247,7 +1247,7 @@ describe("validateAndApplyInboundUpdate", () => { ...test.overrides, }, }; - await runErrorTest(InboundChannelUpdateError.reasons.MalformedDetails, signers[0], { + await runErrorTest(QueuedUpdateError.reasons.MalformedDetails, signers[0], { detailsError: test.error, }); }); @@ -1256,18 +1256,21 @@ describe("validateAndApplyInboundUpdate", () => { }); describe("should handle double signed update", () => { - const updateNonce = 3; + const initialNonce = 4; + let updateNonce; beforeEach(() => { - previousState = createTestChannelState(UpdateType.deposit, { nonce: 2 }).channel; + previousState = createTestChannelState(UpdateType.deposit, { nonce: initialNonce }).channel; }); it("should work without hitting validation for UpdateType.resolve", async () => { const { updatedActiveTransfers, updatedChannel, updatedTransfer } = prepEnv(); + updateNonce = vectorUtils.getNextNonceForUpdate(initialNonce, true); update = createTestChannelUpdate(UpdateType.resolve, { aliceSignature: mkSig("0xaaa"), bobSignature: mkSig("0xbbb"), nonce: updateNonce, + fromIdentifier: previousState.aliceIdentifier, }); // Run test @@ -1310,6 +1313,10 @@ describe("validateAndApplyInboundUpdate", () => { bobSignature: mkSig("0xbbb"), nonce: updateNonce, }); + updateNonce = vectorUtils.getNextNonceForUpdate( + initialNonce, + update.fromIdentifier === previousState.aliceIdentifier, + ); // Run test const result = await validation.validateAndApplyInboundUpdate( @@ -1352,9 +1359,15 @@ describe("validateAndApplyInboundUpdate", () => { chainReader.resolve.resolves(Result.fail(chainErr)); // Create update - update = createTestChannelUpdate(UpdateType.resolve, { aliceSignature, bobSignature, nonce: updateNonce }); + updateNonce = vectorUtils.getNextNonceForUpdate(initialNonce, true); + update = createTestChannelUpdate(UpdateType.resolve, { + aliceSignature, + bobSignature, + nonce: updateNonce, + fromIdentifier: previousState.aliceIdentifier, + }); activeTransfers = [createTestFullHashlockTransferState({ transferId: update.details.transferId })]; - await runErrorTest(InboundChannelUpdateError.reasons.CouldNotGetFinalBalance, undefined, { + await runErrorTest(QueuedUpdateError.reasons.CouldNotGetResolvedBalance, undefined, { chainServiceError: jsonifyError(chainErr), }); }); @@ -1363,9 +1376,15 @@ describe("validateAndApplyInboundUpdate", () => { prepEnv(); // Create update - update = createTestChannelUpdate(UpdateType.resolve, { aliceSignature, bobSignature, nonce: updateNonce }); + updateNonce = vectorUtils.getNextNonceForUpdate(initialNonce, true); + update = createTestChannelUpdate(UpdateType.resolve, { + aliceSignature, + bobSignature, + nonce: updateNonce, + fromIdentifier: previousState.aliceIdentifier, + }); activeTransfers = []; - await runErrorTest(InboundChannelUpdateError.reasons.TransferNotActive, signers[0], { existing: [] }); + await runErrorTest(QueuedUpdateError.reasons.TransferNotActive, signers[0], { existing: [] }); }); it("should fail if applyUpdate fails", async () => { @@ -1376,9 +1395,15 @@ describe("validateAndApplyInboundUpdate", () => { applyUpdateStub.returns(Result.fail(err)); // Create update - update = createTestChannelUpdate(UpdateType.setup, { aliceSignature, bobSignature, nonce: updateNonce }); + updateNonce = vectorUtils.getNextNonceForUpdate(initialNonce, true); + update = createTestChannelUpdate(UpdateType.setup, { + aliceSignature, + bobSignature, + nonce: updateNonce, + fromIdentifier: previousState.aliceIdentifier, + }); activeTransfers = []; - await runErrorTest(InboundChannelUpdateError.reasons.ApplyUpdateFailed, signers[0], { + await runErrorTest(QueuedUpdateError.reasons.ApplyUpdateFailed, signers[0], { applyUpdateError: err.message, applyUpdateContext: err.context, }); @@ -1391,9 +1416,15 @@ describe("validateAndApplyInboundUpdate", () => { validateChannelUpdateSignaturesStub.resolves(Result.fail(new Error("fail"))); // Create update - update = createTestChannelUpdate(UpdateType.setup, { aliceSignature, bobSignature, nonce: updateNonce }); + updateNonce = vectorUtils.getNextNonceForUpdate(initialNonce, true); + update = createTestChannelUpdate(UpdateType.setup, { + aliceSignature, + bobSignature, + nonce: updateNonce, + fromIdentifier: previousState.aliceIdentifier, + }); activeTransfers = []; - await runErrorTest(InboundChannelUpdateError.reasons.BadSignatures, signers[0], { + await runErrorTest(QueuedUpdateError.reasons.BadSignatures, signers[0], { validateSignatureError: "fail", }); }); @@ -1403,7 +1434,7 @@ describe("validateAndApplyInboundUpdate", () => { // Set a passing mocked env prepEnv(); update = createTestChannelUpdate(UpdateType.setup, { nonce: 2 }); - await runErrorTest(InboundChannelUpdateError.reasons.InvalidUpdateNonce, signers[0]); + await runErrorTest(QueuedUpdateError.reasons.InvalidUpdateNonce, signers[0]); }); it("should fail if externalValidation.validateInbound fails", async () => { @@ -1413,7 +1444,7 @@ describe("validateAndApplyInboundUpdate", () => { externalValidationStub.validateInbound.resolves(Result.fail(new Error("fail"))); update = createTestChannelUpdate(UpdateType.setup, { nonce: 1, aliceSignature: undefined }); - await runErrorTest(InboundChannelUpdateError.reasons.ExternalValidationFailed, signers[0], { + await runErrorTest(QueuedUpdateError.reasons.ExternalValidationFailed, signers[0], { externalValidationError: "fail", }); }); @@ -1425,7 +1456,7 @@ describe("validateAndApplyInboundUpdate", () => { validateParamsAndApplyUpdateStub.resolves(Result.fail(new ChainError("fail"))); update = createTestChannelUpdate(UpdateType.setup, { nonce: 1, aliceSignature: undefined }); - await runErrorTest(InboundChannelUpdateError.reasons.ApplyAndValidateInboundFailed, signers[0], { + await runErrorTest(QueuedUpdateError.reasons.ApplyAndValidateInboundFailed, signers[0], { validationError: "fail", validationContext: {}, }); @@ -1438,7 +1469,7 @@ describe("validateAndApplyInboundUpdate", () => { validateChannelUpdateSignaturesStub.resolves(Result.fail(new Error("fail"))); update = createTestChannelUpdate(UpdateType.setup, { nonce: 1, aliceSignature: undefined }); - await runErrorTest(InboundChannelUpdateError.reasons.BadSignatures, signers[0], { signatureError: "fail" }); + await runErrorTest(QueuedUpdateError.reasons.BadSignatures, signers[0], { signatureError: "fail" }); }); it("should fail if generateSignedChannelCommitment fails", async () => { @@ -1448,7 +1479,7 @@ describe("validateAndApplyInboundUpdate", () => { generateSignedChannelCommitmentStub.resolves(Result.fail(new Error("fail"))); update = createTestChannelUpdate(UpdateType.setup, { nonce: 1, aliceSignature: undefined }); - await runErrorTest(InboundChannelUpdateError.reasons.GenerateSignatureFailed, signers[0], { + await runErrorTest(QueuedUpdateError.reasons.GenerateSignatureFailed, signers[0], { signatureError: "fail", }); }); diff --git a/modules/protocol/src/testing/vector.spec.ts b/modules/protocol/src/testing/vector.spec.ts index 214977ebc..f3ed447fe 100644 --- a/modules/protocol/src/testing/vector.spec.ts +++ b/modules/protocol/src/testing/vector.spec.ts @@ -10,30 +10,33 @@ import { MemoryStoreService, expect, MemoryMessagingService, - MemoryLockService, + mkPublicIdentifier, } from "@connext/vector-utils"; import pino from "pino"; import { IVectorChainReader, - ILockService, IMessagingService, IVectorStore, UpdateType, Result, CreateTransferParams, ChainError, + MessagingError, + FullChannelState, + IChannelSigner, } from "@connext/vector-types"; import Sinon from "sinon"; -import { OutboundChannelUpdateError } from "../errors"; +import { QueuedUpdateError, RestoreError } from "../errors"; import { Vector } from "../vector"; import * as vectorSync from "../sync"; +import * as vectorUtils from "../utils"; import { env } from "./env"; +import { chainId } from "./constants"; describe("Vector", () => { let chainReader: Sinon.SinonStubbedInstance; - let lockService: Sinon.SinonStubbedInstance; let messagingService: Sinon.SinonStubbedInstance; let storeService: Sinon.SinonStubbedInstance; @@ -42,13 +45,12 @@ describe("Vector", () => { chainReader.getChannelFactoryBytecode.resolves(Result.ok(mkHash())); chainReader.getChannelMastercopyAddress.resolves(Result.ok(mkAddress())); chainReader.getChainProviders.returns(Result.ok(env.chainProviders)); - lockService = Sinon.createStubInstance(MemoryLockService); messagingService = Sinon.createStubInstance(MemoryMessagingService); storeService = Sinon.createStubInstance(MemoryStoreService); storeService.getChannelStates.resolves([]); // Mock sync outbound Sinon.stub(vectorSync, "outbound").resolves( - Result.ok({ updatedChannel: createTestChannelState(UpdateType.setup).channel }), + Result.ok({ updatedChannel: createTestChannelState(UpdateType.setup).channel, successfullyApplied: "executed" }), ); }); @@ -61,7 +63,6 @@ describe("Vector", () => { const signer = getRandomChannelSigner(); const node = await Vector.connect( messagingService, - lockService, storeService, signer, chainReader as IVectorChainReader, @@ -97,7 +98,6 @@ describe("Vector", () => { chainReader.registerChannel.resolves(Result.ok(undefined)); vector = await Vector.connect( messagingService, - lockService, storeService, signer, chainReader as IVectorChainReader, @@ -112,8 +112,6 @@ describe("Vector", () => { }); const result = await vector.setup(details); expect(result.getError()).to.be.undefined; - expect(lockService.acquireLock.callCount).to.be.eq(1); - expect(lockService.releaseLock.callCount).to.be.eq(1); }); it("should fail if it fails to generate the create2 address", async () => { @@ -123,7 +121,7 @@ describe("Vector", () => { chainReader.getChannelFactoryBytecode.resolves(Result.fail(new ChainError(ChainError.reasons.ProviderNotFound))); const { details } = createTestUpdateParams(UpdateType.setup); const result = await vector.setup(details); - expect(result.getError()?.message).to.be.eq(OutboundChannelUpdateError.reasons.Create2Failed); + expect(result.getError()?.message).to.be.eq(QueuedUpdateError.reasons.Create2Failed); }); describe("should validate parameters", () => { @@ -206,7 +204,7 @@ describe("Vector", () => { const ret = await vector.setup(t.params); expect(ret.isError).to.be.true; const error = ret.getError(); - expect(error?.message).to.be.eq(OutboundChannelUpdateError.reasons.InvalidParams); + expect(error?.message).to.be.eq(QueuedUpdateError.reasons.InvalidParams); expect(error?.context?.paramsError).to.include(t.error); }); } @@ -224,7 +222,6 @@ describe("Vector", () => { vector = await Vector.connect( messagingService, - lockService, storeService, signer, chainReader as IVectorChainReader, @@ -237,8 +234,6 @@ describe("Vector", () => { const { details } = createTestUpdateParams(UpdateType.deposit, { channelAddress }); const result = await vector.deposit({ ...details, channelAddress }); expect(result.getError()).to.be.undefined; - expect(lockService.acquireLock.callCount).to.be.eq(1); - expect(lockService.releaseLock.callCount).to.be.eq(1); }); describe("should validate parameters", () => { @@ -276,7 +271,7 @@ describe("Vector", () => { const ret = await vector.deposit(params); expect(ret.isError).to.be.true; const err = ret.getError(); - expect(err?.message).to.be.eq(OutboundChannelUpdateError.reasons.InvalidParams); + expect(err?.message).to.be.eq(QueuedUpdateError.reasons.InvalidParams); expect(err?.context?.paramsError).to.include(error); }); } @@ -294,7 +289,6 @@ describe("Vector", () => { vector = await Vector.connect( messagingService, - lockService, storeService, signer, chainReader as IVectorChainReader, @@ -307,8 +301,6 @@ describe("Vector", () => { const { details } = createTestUpdateParams(UpdateType.create, { channelAddress }); const result = await vector.create({ ...details, channelAddress }); expect(result.getError()).to.be.undefined; - expect(lockService.acquireLock.callCount).to.be.eq(1); - expect(lockService.releaseLock.callCount).to.be.eq(1); }); describe("should validate parameters", () => { @@ -384,7 +376,7 @@ describe("Vector", () => { const ret = await vector.create(params); expect(ret.isError).to.be.true; const err = ret.getError(); - expect(err?.message).to.be.eq(OutboundChannelUpdateError.reasons.InvalidParams); + expect(err?.message).to.be.eq(QueuedUpdateError.reasons.InvalidParams); expect(err?.context?.paramsError).to.include(error); }); } @@ -402,7 +394,6 @@ describe("Vector", () => { vector = await Vector.connect( messagingService, - lockService, storeService, signer, chainReader as IVectorChainReader, @@ -415,8 +406,6 @@ describe("Vector", () => { const { details } = createTestUpdateParams(UpdateType.resolve, { channelAddress }); const result = await vector.resolve({ ...details, channelAddress }); expect(result.getError()).to.be.undefined; - expect(lockService.acquireLock.callCount).to.be.eq(1); - expect(lockService.releaseLock.callCount).to.be.eq(1); }); describe("should validate parameters", () => { @@ -461,10 +450,256 @@ describe("Vector", () => { const ret = await vector.resolve(params); expect(ret.isError).to.be.true; const err = ret.getError(); - expect(err?.message).to.be.eq(OutboundChannelUpdateError.reasons.InvalidParams); + expect(err?.message).to.be.eq(QueuedUpdateError.reasons.InvalidParams); expect(err?.context?.paramsError).to.include(error); }); } }); }); + + describe("Vector.restore", () => { + let vector: Vector; + const channelAddress: string = mkAddress("0xccc"); + let counterpartyIdentifier: string; + let channel: FullChannelState; + let sigValidationStub: Sinon.SinonStub; + + beforeEach(async () => { + const signer = getRandomChannelSigner(); + const counterparty = getRandomChannelSigner(); + counterpartyIdentifier = counterparty.publicIdentifier; + + vector = await Vector.connect( + messagingService, + storeService, + signer, + chainReader as IVectorChainReader, + pino(), + false, + ); + + sigValidationStub = Sinon.stub(vectorUtils, "validateChannelSignatures"); + + channel = createTestChannelState(UpdateType.deposit, { + channelAddress, + aliceIdentifier: counterpartyIdentifier, + networkContext: { chainId }, + nonce: 5, + }).channel; + messagingService.sendRestoreStateMessage.resolves( + Result.ok({ + channel, + activeTransfers: [], + }), + ); + chainReader.getChannelAddress.resolves(Result.ok(channel.channelAddress)); + sigValidationStub.resolves(Result.ok(undefined)); + }); + + // UNIT TESTS + describe("should fail if the parameters are malformed", () => { + const paramTests: ParamValidationTest[] = [ + { + name: "should fail if parameters.chainId is invalid", + params: { + chainId: "fail", + counterpartyIdentifier: mkPublicIdentifier(), + }, + error: "should be number", + }, + { + name: "should fail if parameters.chainId is undefined", + params: { + chainId: undefined, + counterpartyIdentifier: mkPublicIdentifier(), + }, + error: "should have required property 'chainId'", + }, + { + name: "should fail if parameters.counterpartyIdentifier is invalid", + params: { + chainId, + counterpartyIdentifier: 1, + }, + error: "should be string", + }, + { + name: "should fail if parameters.counterpartyIdentifier is undefined", + params: { + chainId, + counterpartyIdentifier: undefined, + }, + error: "should have required property 'counterpartyIdentifier'", + }, + ]; + for (const { name, error, params } of paramTests) { + it(name, async () => { + const result = await vector.restoreState(params); + expect(result.isError).to.be.true; + expect(result.getError()?.message).to.be.eq(QueuedUpdateError.reasons.InvalidParams); + expect(result.getError()?.context.paramsError).to.be.eq(error); + }); + } + }); + + describe("restore initiator side", () => { + const runWithFailure = async (message: string) => { + const result = await vector.restoreState({ chainId, counterpartyIdentifier }); + expect(result.getError()).to.not.be.undefined; + expect(result.getError()?.message).to.be.eq(message); + }; + it("should fail if it receives an error", async () => { + messagingService.sendRestoreStateMessage.resolves( + Result.fail(new MessagingError(MessagingError.reasons.Timeout)), + ); + + await runWithFailure(MessagingError.reasons.Timeout); + }); + + it("should fail if there is no channel or active transfers provided", async () => { + messagingService.sendRestoreStateMessage.resolves( + Result.ok({ channel: undefined, activeTransfers: undefined }) as any, + ); + + await runWithFailure(RestoreError.reasons.NoData); + }); + + it("should fail if chainReader.geChannelAddress fails", async () => { + chainReader.getChannelAddress.resolves(Result.fail(new ChainError("fail"))); + + await runWithFailure(RestoreError.reasons.GetChannelAddressFailed); + }); + + it("should fail if it gives the wrong channel by channel address", async () => { + chainReader.getChannelAddress.resolves(Result.ok(mkAddress("0x334455666666ccccc"))); + + await runWithFailure(RestoreError.reasons.InvalidChannelAddress); + }); + + it("should fail if channel.latestUpdate is malsigned", async () => { + sigValidationStub.resolves(Result.fail(new Error("fail"))); + + await runWithFailure(RestoreError.reasons.InvalidSignatures); + }); + + it("should fail if channel.merkleRoot is incorrect", async () => { + messagingService.sendRestoreStateMessage.resolves( + Result.ok({ + channel: { ...channel, merkleRoot: mkHash("0xddddeeefffff") }, + activeTransfers: [], + }), + ); + + await runWithFailure(RestoreError.reasons.InvalidMerkleRoot); + }); + + it("should fail if the state is syncable", async () => { + storeService.getChannelState.resolves(channel); + + await runWithFailure(RestoreError.reasons.SyncableState); + }); + + it("should fail if store.saveChannelStateAndTransfers fails", async () => { + storeService.getChannelState.resolves(undefined); + storeService.saveChannelStateAndTransfers.rejects(new Error("fail")); + + await runWithFailure(RestoreError.reasons.SaveChannelFailed); + }); + }); + + describe("restore responder side", () => { + // Test with memory messaging service + stubs to properly trigger + // callback + let memoryMessaging: MemoryMessagingService; + let signer: IChannelSigner; + beforeEach(async () => { + memoryMessaging = new MemoryMessagingService(); + signer = getRandomChannelSigner(); + vector = await Vector.connect( + // Use real messaging service to test properly + memoryMessaging, + storeService, + signer, + chainReader as IVectorChainReader, + pino(), + false, + ); + }); + + it("should do nothing if it receives message from itself", async () => { + const response = await memoryMessaging.sendRestoreStateMessage( + Result.ok({ chainId }), + signer.publicIdentifier, + signer.publicIdentifier, + 500, + ); + expect(response.getError()?.message).to.be.eq(MessagingError.reasons.Timeout); + expect(storeService.getChannelStateByParticipants.callCount).to.be.eq(0); + }); + + it("should do nothing if it receives an error", async () => { + const response = await memoryMessaging.sendRestoreStateMessage( + Result.fail(new Error("fail") as any), + signer.publicIdentifier, + mkPublicIdentifier(), + 500, + ); + expect(response.getError()?.message).to.be.eq(MessagingError.reasons.Timeout); + expect(storeService.getChannelStateByParticipants.callCount).to.be.eq(0); + }); + + // Hard to test because of messaging service implementation + it.skip("should do nothing if message is malformed", async () => { + const response = await memoryMessaging.sendRestoreStateMessage( + Result.ok({ test: "test" } as any), + signer.publicIdentifier, + mkPublicIdentifier(), + 500, + ); + expect(response.getError()?.message).to.be.eq(MessagingError.reasons.Timeout); + expect(storeService.getChannelStateByParticipants.callCount).to.be.eq(0); + }); + + it("should send error if it cannot get channel", async () => { + storeService.getChannelStateByParticipants.rejects(new Error("fail")); + const response = await memoryMessaging.sendRestoreStateMessage( + Result.ok({ chainId }), + signer.publicIdentifier, + mkPublicIdentifier(), + ); + expect(response.getError()?.message).to.be.eq(RestoreError.reasons.CouldNotGetChannel); + expect(storeService.getChannelStateByParticipants.callCount).to.be.eq(1); + }); + + it("should send error if it cannot get active transfers", async () => { + storeService.getChannelStateByParticipants.resolves(createTestChannelState(UpdateType.deposit).channel); + storeService.getActiveTransfers.rejects(new Error("fail")); + const response = await memoryMessaging.sendRestoreStateMessage( + Result.ok({ chainId }), + signer.publicIdentifier, + mkPublicIdentifier(), + ); + expect(response.getError()?.message).to.be.eq(RestoreError.reasons.CouldNotGetActiveTransfers); + expect(storeService.getChannelStateByParticipants.callCount).to.be.eq(1); + }); + + it("should send correct information", async () => { + const channel = createTestChannelState(UpdateType.deposit).channel; + storeService.getChannelStateByParticipants.resolves(channel); + storeService.getActiveTransfers.resolves([]); + const response = await memoryMessaging.sendRestoreStateMessage( + Result.ok({ chainId }), + signer.publicIdentifier, + mkPublicIdentifier(), + ); + expect(response.getValue()).to.be.deep.eq({ channel, activeTransfers: [] }); + }); + }); + + it("should work", async () => { + const result = await vector.restoreState({ chainId, counterpartyIdentifier }); + expect(result.getError()).to.be.undefined; + expect(result.getValue()).to.be.deep.eq(channel); + }); + }); }); diff --git a/modules/protocol/src/update.ts b/modules/protocol/src/update.ts index 4bbb067bf..6c4d58e63 100644 --- a/modules/protocol/src/update.ts +++ b/modules/protocol/src/update.ts @@ -2,8 +2,7 @@ import { getSignerAddressFromPublicIdentifier, hashTransferState, getTransferId, - generateMerkleTreeData, - hashCoreTransferState, + generateMerkleRoot, } from "@connext/vector-utils"; import { UpdateType, @@ -26,7 +25,13 @@ import { HashZero, AddressZero } from "@ethersproject/constants"; import { BaseLogger } from "pino"; import { ApplyUpdateError, CreateUpdateError } from "./errors"; -import { generateSignedChannelCommitment, getUpdatedChannelBalance, mergeAssetIds, reconcileDeposit } from "./utils"; +import { + generateSignedChannelCommitment, + getNextNonceForUpdate, + getUpdatedChannelBalance, + mergeAssetIds, + reconcileDeposit, +} from "./utils"; // Should return a state with the given update applied // It is assumed here that the update is validated before @@ -74,7 +79,7 @@ export function applyUpdate( return Result.ok({ updatedActiveTransfers: [...previousActiveTransfers], updatedChannel: { - nonce: 1, + nonce: update.nonce, channelAddress, timeout, alice: getSignerAddressFromPublicIdentifier(fromIdentifier), @@ -361,6 +366,7 @@ function generateSetupUpdate( meta: params.details.meta ?? {}, }, assetId: AddressZero, + id: params.id, }; return unsigned; @@ -493,7 +499,7 @@ async function generateCreateUpdate( initiatorIdentifier, responderIdentifier: signer.publicIdentifier === initiatorIdentifier ? counterpartyId : signer.address, }; - const { tree, root } = generateMerkleTreeData([...transfers, transferState]); + const merkleRoot = generateMerkleRoot([...transfers, transferState]); // Create the update from the user provided params const channelBalance = getUpdatedChannelBalance(UpdateType.create, assetId, balance, state, transferState.initiator); @@ -508,8 +514,7 @@ async function generateCreateUpdate( balance, transferInitialState, transferEncodings: [stateEncoding, resolverEncoding], - merkleProofData: tree.getHexProof(hashCoreTransferState(transferState)), - merkleRoot: root, + merkleRoot, meta: { ...(meta ?? {}), createdAt: Date.now() }, }, }; @@ -542,7 +547,7 @@ async function generateResolveUpdate( }), ); } - const { root } = generateMerkleTreeData(transfers.filter((x) => x.transferId !== transferId)); + const merkleRoot = generateMerkleRoot(transfers.filter((t) => t.transferId !== transferId)); // Get the final transfer balance from contract const transferBalanceResult = await chainService.resolve( @@ -576,7 +581,7 @@ async function generateResolveUpdate( transferId, transferDefinition: transferToResolve.transferDefinition, transferResolver, - merkleRoot: root, + merkleRoot, meta: { ...(transferToResolve.meta ?? {}), ...(meta ?? {}) }, }, }; @@ -593,15 +598,16 @@ function generateBaseUpdate( params: UpdateParams, signer: IChannelSigner, initiatorIdentifier: string, -): Pick, "channelAddress" | "nonce" | "fromIdentifier" | "toIdentifier" | "type"> { +): Pick, "channelAddress" | "nonce" | "fromIdentifier" | "toIdentifier" | "type" | "id"> { const isInitiator = signer.publicIdentifier === initiatorIdentifier; const counterparty = signer.publicIdentifier === state.bobIdentifier ? state.aliceIdentifier : state.bobIdentifier; return { - nonce: state.nonce + 1, + nonce: getNextNonceForUpdate(state.nonce, initiatorIdentifier === state.aliceIdentifier), channelAddress: state.channelAddress, type: params.type, fromIdentifier: initiatorIdentifier, toIdentifier: isInitiator ? counterparty : signer.publicIdentifier, + id: params.id, }; } diff --git a/modules/protocol/src/utils.ts b/modules/protocol/src/utils.ts index ae81fa56f..77f922c10 100644 --- a/modules/protocol/src/utils.ts +++ b/modules/protocol/src/utils.ts @@ -19,12 +19,20 @@ import { UpdateParamsMap, UpdateType, ChainError, + UpdateIdentifier, } from "@connext/vector-types"; import { getAddress } from "@ethersproject/address"; import { BigNumber } from "@ethersproject/bignumber"; -import { hashChannelCommitment, validateChannelUpdateSignatures } from "@connext/vector-utils"; +import { + getSignerAddressFromPublicIdentifier, + hashChannelCommitment, + hashTransferState, + validateChannelUpdateSignatures, + recoverAddressFromChannelMessage, +} from "@connext/vector-utils"; import Ajv from "ajv"; import { BaseLogger, Level } from "pino"; +import { QueuedUpdateError } from "./errors"; const ajv = new Ajv(); @@ -38,6 +46,16 @@ export const validateSchema = (obj: any, schema: any): undefined | string => { return undefined; }; +export function validateParamSchema(params: any, schema: any): undefined | QueuedUpdateError { + const error = validateSchema(params, schema); + if (error) { + return new QueuedUpdateError(QueuedUpdateError.reasons.InvalidParams, params, undefined, { + paramsError: error, + }); + } + return undefined; +} + // NOTE: If you do *NOT* use this function within the protocol, it becomes // very difficult to write proper unit tests. When the same utility is imported // as: @@ -57,14 +75,31 @@ export async function validateChannelSignatures( return validateChannelUpdateSignatures(state, aliceSignature, bobSignature, requiredSigners, logger); } +export async function validateChannelUpdateIdSignature( + identifier: UpdateIdentifier, + initiatorIdentifier: string, +): Promise> { + try { + const recovered = await recoverAddressFromChannelMessage(identifier.id, identifier.signature); + if (recovered !== getSignerAddressFromPublicIdentifier(initiatorIdentifier)) { + return Result.fail(new Error(``)); + } + return Result.ok(undefined); + } catch (e) { + return Result.fail(new Error(`Failed to recover signer from update id: ${e.message}`)); + } +} + export const extractContextFromStore = async ( storeService: IVectorStore, channelAddress: string, + updateId: string, ): Promise< Result< { activeTransfers: FullTransferState[]; channelState: FullChannelState | undefined; + update: ChannelUpdate | undefined; }, Error > @@ -72,6 +107,7 @@ export const extractContextFromStore = async ( // First, pull all information out from the store let activeTransfers: FullTransferState[]; let channelState: FullChannelState | undefined; + let update: ChannelUpdate | undefined; let storeMethod = "getChannelState"; try { // will always need the previous state @@ -79,6 +115,8 @@ export const extractContextFromStore = async ( // will only need active transfers for create/resolve storeMethod = "getActiveTransfers"; activeTransfers = await storeService.getActiveTransfers(channelAddress); + storeMethod = "getUpdateById"; + update = await storeService.getUpdateById(updateId); } catch (e) { return Result.fail(new Error(`${storeMethod} failed: ${e.message}`)); } @@ -86,9 +124,26 @@ export const extractContextFromStore = async ( return Result.ok({ activeTransfers, channelState, + update, }); }; +export const persistChannel = async ( + storeService: IVectorStore, + updatedChannel: FullChannelState, + updatedTransfer?: FullTransferState, +) => { + try { + await storeService.saveChannelState(updatedChannel, updatedTransfer); + return Result.ok({ + updatedChannel, + updatedTransfer, + }); + } catch (e) { + return Result.fail(new Error(`Failed to persist data: ${e.message}`)); + } +}; + // Channels store `ChannelUpdate` types as the `latestUpdate` field, which // must be converted to the `UpdateParams when syncing export function getParamsFromUpdate( @@ -159,9 +214,37 @@ export function getParamsFromUpdate( channelAddress, type, details: paramDetails as UpdateParamsMap[T], + id: update.id, }); } +export function getTransferFromUpdate( + update: ChannelUpdate, + channel: FullChannelState, +): FullTransferState { + return { + balance: update.details.balance, + assetId: update.assetId, + transferId: update.details.transferId, + channelAddress: update.channelAddress, + transferDefinition: update.details.transferDefinition, + transferEncodings: update.details.transferEncodings, + transferTimeout: update.details.transferTimeout, + initialStateHash: hashTransferState(update.details.transferInitialState, update.details.transferEncodings[0]), + transferState: update.details.transferInitialState, + channelFactoryAddress: channel.networkContext.channelFactoryAddress, + chainId: channel.networkContext.chainId, + transferResolver: undefined, + initiator: getSignerAddressFromPublicIdentifier(update.fromIdentifier), + responder: getSignerAddressFromPublicIdentifier(update.toIdentifier), + meta: { ...(update.details.meta ?? {}), createdAt: Date.now() }, + inDispute: false, + channelNonce: update.nonce, + initiatorIdentifier: update.fromIdentifier, + responderIdentifier: update.toIdentifier, + }; +} + // This function signs the state after the update is applied, // not for the update that exists export async function generateSignedChannelCommitment( @@ -381,3 +464,26 @@ export const mergeAssetIds = (channel: FullChannelState): FullChannelState => { defundNonces, }; }; + +// Returns the first unused nonce for the given participant. +// Nonces alternate back and forth like so: +// 0: Alice +// 1: Alice +// 2: Bob +// 3: Bob +// 4: Alice +// 5: Alice +// 6: Bob +// 7: Bob +// +// Examples: +// (0, true) => 1 +// (0, false) => 2 +// (1, true) => 4 +export function getNextNonceForUpdate(currentNonce: number, isAlice: boolean): number { + let rotation = currentNonce % 4; + let currentlyMe = rotation < 2 === isAlice; + let top = currentNonce % 2 === 1; + let offset = currentlyMe ? (top ? 3 : 1) : top ? 1 : 2; + return currentNonce + offset; +} diff --git a/modules/protocol/src/validate.ts b/modules/protocol/src/validate.ts index 26c792880..feadaac94 100644 --- a/modules/protocol/src/validate.ts +++ b/modules/protocol/src/validate.ts @@ -28,12 +28,14 @@ import { isAddress, getAddress } from "@ethersproject/address"; import { BigNumber } from "@ethersproject/bignumber"; import { BaseLogger } from "pino"; -import { InboundChannelUpdateError, OutboundChannelUpdateError, ValidationError } from "./errors"; +import { QueuedUpdateError, ValidationError } from "./errors"; import { applyUpdate, generateAndApplyUpdate } from "./update"; import { generateSignedChannelCommitment, + getNextNonceForUpdate, getParamsFromUpdate, validateChannelSignatures, + validateChannelUpdateIdSignature, validateSchema, } from "./utils"; @@ -69,7 +71,21 @@ export async function validateUpdateParams( return handleError(ValidationError.reasons.InDispute); } - const { type, channelAddress, details } = params; + const { type, channelAddress, details, id } = params; + + // if this is *not* the initiator, verify the update id sig. + // if it is, they are only hurting themselves by not providing + // it correctly + if (signer.publicIdentifier !== initiatorIdentifier) { + const recovered = await validateChannelUpdateIdSignature(id, initiatorIdentifier); + if (recovered.isError) { + return Result.fail( + new ValidationError(ValidationError.reasons.UpdateIdSigInvalid, params, previousState, { + recoveryError: jsonifyError(recovered.getError()!), + }), + ); + } + } if (previousState && channelAddress !== previousState.channelAddress) { return handleError(ValidationError.reasons.InvalidChannelAddress); @@ -286,7 +302,7 @@ export const validateParamsAndApplyUpdate = async ( updatedActiveTransfers: FullTransferState[]; updatedTransfer: FullTransferState | undefined; }, - OutboundChannelUpdateError + QueuedUpdateError > > => { // Verify params are valid @@ -303,15 +319,10 @@ export const validateParamsAndApplyUpdate = async ( // strip useful context from validation error const { state, params, ...usefulContext } = error.context; return Result.fail( - new OutboundChannelUpdateError( - OutboundChannelUpdateError.reasons.OutboundValidationFailed, - params, - previousState, - { - validationError: error.message, - validationContext: usefulContext, - }, - ), + new QueuedUpdateError(QueuedUpdateError.reasons.OutboundValidationFailed, params, previousState, { + validationError: error.message, + validationContext: usefulContext, + }), ); } @@ -320,14 +331,9 @@ export const validateParamsAndApplyUpdate = async ( const externalRes = await externalValidation.validateOutbound(params, previousState, activeTransfers); if (externalRes.isError) { return Result.fail( - new OutboundChannelUpdateError( - OutboundChannelUpdateError.reasons.ExternalValidationFailed, - params, - previousState, - { - externalValidationError: externalRes.getError()!.message, - }, - ), + new QueuedUpdateError(QueuedUpdateError.reasons.ExternalValidationFailed, params, previousState, { + externalValidationError: externalRes.getError()!.message, + }), ); } } @@ -348,7 +354,7 @@ export const validateParamsAndApplyUpdate = async ( // strip useful context from validation error const { state, params: updateParams, ...usefulContext } = error.context; return Result.fail( - new OutboundChannelUpdateError(OutboundChannelUpdateError.reasons.GenerateUpdateFailed, params, previousState, { + new QueuedUpdateError(QueuedUpdateError.reasons.GenerateUpdateFailed, params, previousState, { generateError: error.message, generateContext: usefulContext, }), @@ -376,14 +382,14 @@ export async function validateAndApplyInboundUpdate( updatedActiveTransfers: FullTransferState[]; updatedTransfer?: FullTransferState; }, - InboundChannelUpdateError + QueuedUpdateError > > { // Make sure update + details have proper structure before proceeding const invalidUpdate = validateSchema(update, TChannelUpdate); if (invalidUpdate) { return Result.fail( - new InboundChannelUpdateError(InboundChannelUpdateError.reasons.MalformedUpdate, update, previousState, { + new QueuedUpdateError(QueuedUpdateError.reasons.MalformedUpdate, update, previousState, { updateError: invalidUpdate, }), ); @@ -397,24 +403,33 @@ export async function validateAndApplyInboundUpdate( const invalid = validateSchema(update.details, schemas[update.type]); if (invalid) { return Result.fail( - new InboundChannelUpdateError(InboundChannelUpdateError.reasons.MalformedDetails, update, previousState, { + new QueuedUpdateError(QueuedUpdateError.reasons.MalformedDetails, update, previousState, { detailsError: invalid, }), ); } // Shortcut: check if the incoming update is double signed. If it is, and the - // nonce, only increments by 1, then it is safe to apply update and proceed - // without any additional validation. - const expected = (previousState?.nonce ?? 0) + 1; + // nonce, only increments by 1 transition, then it is safe to apply update + // and proceed without any additional validation. + const aliceSentUpdate = + update.type === UpdateType.setup ? true : previousState!.aliceIdentifier === update.fromIdentifier; + const expected = getNextNonceForUpdate(previousState?.nonce ?? 0, aliceSentUpdate); if (update.nonce !== expected) { - return Result.fail( - new InboundChannelUpdateError(InboundChannelUpdateError.reasons.InvalidUpdateNonce, update, previousState), - ); + return Result.fail(new QueuedUpdateError(QueuedUpdateError.reasons.InvalidUpdateNonce, update, previousState)); } // Handle double signed updates without validating params if (update.aliceSignature && update.bobSignature) { + // Verify the update.id.signature is correct (should be initiator) + const recovered = await validateChannelUpdateIdSignature(update.id, update.fromIdentifier); + if (recovered.isError) { + return Result.fail( + new QueuedUpdateError(QueuedUpdateError.reasons.UpdateIdSigInvalid, update, previousState, { + recoveryError: jsonifyError(recovered.getError()!), + }), + ); + } // Get final transfer balance (required when applying resolve updates); let finalTransferBalance: Balance | undefined = undefined; if (update.type === UpdateType.resolve) { @@ -424,7 +439,7 @@ export async function validateAndApplyInboundUpdate( ); if (!transfer) { return Result.fail( - new InboundChannelUpdateError(InboundChannelUpdateError.reasons.TransferNotActive, update, previousState, { + new QueuedUpdateError(QueuedUpdateError.reasons.TransferNotActive, update, previousState, { existing: activeTransfers.map((t) => t.transferId), }), ); @@ -436,14 +451,9 @@ export async function validateAndApplyInboundUpdate( if (transferBalanceResult.isError) { return Result.fail( - new InboundChannelUpdateError( - InboundChannelUpdateError.reasons.CouldNotGetFinalBalance, - update, - previousState, - { - chainServiceError: jsonifyError(transferBalanceResult.getError()!), - }, - ), + new QueuedUpdateError(QueuedUpdateError.reasons.CouldNotGetResolvedBalance, update, previousState, { + chainServiceError: jsonifyError(transferBalanceResult.getError()!), + }), ); } finalTransferBalance = transferBalanceResult.getValue(); @@ -452,7 +462,7 @@ export async function validateAndApplyInboundUpdate( if (applyRes.isError) { const { state, params, update: errUpdate, ...usefulContext } = applyRes.getError()?.context; return Result.fail( - new InboundChannelUpdateError(InboundChannelUpdateError.reasons.ApplyUpdateFailed, update, previousState, { + new QueuedUpdateError(QueuedUpdateError.reasons.ApplyUpdateFailed, update, previousState, { applyUpdateError: applyRes.getError()?.message, applyUpdateContext: usefulContext, }), @@ -468,7 +478,7 @@ export async function validateAndApplyInboundUpdate( ); if (sigRes.isError) { return Result.fail( - new InboundChannelUpdateError(InboundChannelUpdateError.reasons.BadSignatures, update, previousState, { + new QueuedUpdateError(QueuedUpdateError.reasons.BadSignatures, update, previousState, { validateSignatureError: sigRes.getError()?.message, }), ); @@ -492,7 +502,7 @@ export async function validateAndApplyInboundUpdate( const inboundRes = await externalValidation.validateInbound(update, previousState, activeTransfers); if (inboundRes.isError) { return Result.fail( - new InboundChannelUpdateError(InboundChannelUpdateError.reasons.ExternalValidationFailed, update, previousState, { + new QueuedUpdateError(QueuedUpdateError.reasons.ExternalValidationFailed, update, previousState, { externalValidationError: inboundRes.getError()?.message, }), ); @@ -503,7 +513,7 @@ export async function validateAndApplyInboundUpdate( const params = getParamsFromUpdate(update); if (params.isError) { return Result.fail( - new InboundChannelUpdateError(InboundChannelUpdateError.reasons.CouldNotGetParams, update, previousState, { + new QueuedUpdateError(QueuedUpdateError.reasons.CouldNotGetParams, update, previousState, { getParamsError: params.getError()?.message, }), ); @@ -522,15 +532,10 @@ export async function validateAndApplyInboundUpdate( // strip useful context from validation error const { state, params, ...usefulContext } = validRes.getError()!.context; return Result.fail( - new InboundChannelUpdateError( - InboundChannelUpdateError.reasons.ApplyAndValidateInboundFailed, - update, - previousState, - { - validationError: validRes.getError()!.message, - validationContext: usefulContext, - }, - ), + new QueuedUpdateError(QueuedUpdateError.reasons.ApplyAndValidateInboundFailed, update, previousState, { + validationError: validRes.getError()!.message, + validationContext: usefulContext, + }), ); } @@ -545,8 +550,12 @@ export async function validateAndApplyInboundUpdate( logger, ); if (sigRes.isError) { + logger?.error( + { generatedParams: params.getValue(), generatedUpdate: updatedChannel.latestUpdate, update, previousState }, + "Failed to validate initiator sig", + ); return Result.fail( - new InboundChannelUpdateError(InboundChannelUpdateError.reasons.BadSignatures, update, previousState, { + new QueuedUpdateError(QueuedUpdateError.reasons.BadSignatures, update, previousState, { signatureError: sigRes.getError()?.message, }), ); @@ -562,7 +571,7 @@ export async function validateAndApplyInboundUpdate( ); if (signedRes.isError) { return Result.fail( - new InboundChannelUpdateError(InboundChannelUpdateError.reasons.GenerateSignatureFailed, update, previousState, { + new QueuedUpdateError(QueuedUpdateError.reasons.GenerateSignatureFailed, update, previousState, { signatureError: signedRes.getError()?.message, }), ); diff --git a/modules/protocol/src/vector.ts b/modules/protocol/src/vector.ts index 7667d1f2c..8cdf59888 100644 --- a/modules/protocol/src/vector.ts +++ b/modules/protocol/src/vector.ts @@ -1,4 +1,3 @@ -import { ChannelMastercopy } from "@connext/vector-contracts"; import { ChannelUpdate, ChannelUpdateEvent, @@ -6,7 +5,6 @@ import { FullTransferState, IChannelSigner, IExternalValidation, - ILockService, IMessagingService, IVectorChainReader, IVectorProtocol, @@ -20,15 +18,31 @@ import { TChannelUpdate, ProtocolError, jsonifyError, - ChainReaderEvents, + Values, + UpdateIdentifier, + PROTOCOL_VERSION, } from "@connext/vector-types"; -import { getCreate2MultisigAddress, getRandomBytes32 } from "@connext/vector-utils"; +import { v4 as uuidV4 } from "uuid"; +import { + getCreate2MultisigAddress, + getRandomBytes32, + delay, + getSignerAddressFromPublicIdentifier, + generateMerkleRoot, +} from "@connext/vector-utils"; import { Evt } from "evt"; import pino from "pino"; -import { OutboundChannelUpdateError } from "./errors"; -import * as sync from "./sync"; -import { validateSchema } from "./utils"; +import { QueuedUpdateError, RestoreError, ValidationError } from "./errors"; +import { Cancellable, OtherUpdate, SelfUpdate, SerializedQueue } from "./queue"; +import { outbound, inbound, OtherUpdateResult, SelfUpdateResult } from "./sync"; +import { + extractContextFromStore, + getNextNonceForUpdate, + persistChannel, + validateChannelSignatures, + validateParamSchema, +} from "./utils"; type EvtContainer = { [K in keyof ProtocolEventPayloadsMap]: Evt }; @@ -37,10 +51,16 @@ export class Vector implements IVectorProtocol { [ProtocolEventName.CHANNEL_UPDATE_EVENT]: Evt.create(), }; + // Hold the serialized queue for each channel + // Do not interact with this directly. Always use getQueueAsync() + private queues: Map | undefined>> = new Map(); + + // Hold a flag to indicate whether or not a channel is being restored + private restorations: Map = new Map(); + // make it private so the only way to create the class is to use `connect` private constructor( private readonly messagingService: IMessagingService, - private readonly lockService: ILockService, private readonly storeService: IVectorStore, private readonly signer: IChannelSigner, private readonly chainReader: IVectorChainReader, @@ -51,7 +71,6 @@ export class Vector implements IVectorProtocol { static async connect( messagingService: IMessagingService, - lockService: ILockService, storeService: IVectorStore, signer: IChannelSigner, chainReader: IVectorChainReader, @@ -75,7 +94,6 @@ export class Vector implements IVectorProtocol { // channel is `setup` plus is not in dispute const node = await new Vector( messagingService, - lockService, storeService, signer, chainReader, @@ -96,91 +114,360 @@ export class Vector implements IVectorProtocol { return this.signer.publicIdentifier; } - // separate out this function so that we can atomically return and release the lock - private async lockedOperation( - params: UpdateParams, - ): Promise> { - // Send the update to counterparty - const outboundRes = await sync.outbound( - params, - this.storeService, - this.chainReader, - this.messagingService, - this.externalValidationService, - this.signer, - this.logger, + // Primary protocol execution from the leader side + private async executeUpdate(params: UpdateParams): Promise> { + const method = "executeUpdate"; + const methodId = getRandomBytes32(); + this.logger.debug( + { + method, + methodId, + params, + channelAddress: params.channelAddress, + initiator: this.publicIdentifier, + }, + "Executing update", ); - if (outboundRes.isError) { - this.logger.error({ - method: "lockedOperation", - variable: "outboundRes", - error: jsonifyError(outboundRes.getError()!), - }); - return outboundRes as Result; + + const queue = await this.getQueueAsync(this.publicIdentifier, params); + if (queue === undefined) { + return Result.fail(new QueuedUpdateError(QueuedUpdateError.reasons.ChannelNotFound, params)); + } + + // Add operation to queue + const selfResult = await queue.executeSelfAsync({ params }); + + if (selfResult.isError) { + return Result.fail(selfResult.getError()!); } - // Post to channel update evt - const { updatedChannel, updatedTransfers, updatedTransfer } = outboundRes.getValue(); + const { updatedTransfer, updatedChannel, updatedTransfers } = selfResult.getValue(); this.evts[ProtocolEventName.CHANNEL_UPDATE_EVENT].post({ - updatedChannelState: updatedChannel, - updatedTransfers, updatedTransfer, + updatedTransfers, + updatedChannelState: updatedChannel, }); - return Result.ok(outboundRes.getValue().updatedChannel); + + return Result.ok(updatedChannel); } - // Primary protocol execution from the leader side - private async executeUpdate( - params: UpdateParams, - ): Promise> { - const method = "executeUpdate"; - const methodId = getRandomBytes32(); - this.logger.debug({ - method, - methodId, - step: "start", - params, - channelAddress: params.channelAddress, - updateSender: this.publicIdentifier, - }); - let aliceIdentifier: string; - let bobIdentifier: string; - let channel: FullChannelState | undefined; - if (params.type === UpdateType.setup) { - aliceIdentifier = this.publicIdentifier; - bobIdentifier = (params as UpdateParams<"setup">).details.counterpartyIdentifier; - } else { - channel = await this.storeService.getChannelState(params.channelAddress); - if (!channel) { - return Result.fail(new OutboundChannelUpdateError(OutboundChannelUpdateError.reasons.ChannelNotFound, params)); + private createChannelQueue( + channelAddress: string, + aliceIdentifier: string, + ): SerializedQueue { + // Create a cancellable outbound function to be used when initiating updates + const cancellableOutbound: Cancellable = async ( + initiated: SelfUpdate, + cancel: Promise, + ) => { + const cancelPromise = new Promise(async (resolve) => { + let ret; + try { + ret = await cancel; + } catch (e) { + // TODO: cancel promise fails? + ret = e; + } + return resolve({ cancelled: true, value: ret }); + }); + const outboundPromise = new Promise(async (resolve) => { + const storeRes = await extractContextFromStore( + this.storeService, + initiated.params.channelAddress, + initiated.params.id.id, + ); + if (storeRes.isError) { + // Return failure + return Result.fail( + new QueuedUpdateError(QueuedUpdateError.reasons.StoreFailure, initiated.params, undefined, { + storeError: storeRes.getError()?.message, + }), + ); + } + const { channelState, activeTransfers, update } = storeRes.getValue(); + if (update && update.aliceSignature && update.bobSignature) { + // Update has already been executed, see explanation in + // types/channel.ts for `UpdateIdentifier` + const transfer = [UpdateType.create, UpdateType.resolve].includes(update.type) + ? await this.storeService.getTransferState(update.details.transferId) + : undefined; + return resolve({ + cancelled: false, + value: Result.ok({ + updatedTransfer: transfer, + updatedChannel: channelState, + updatedTransfers: activeTransfers, + }), + successfullyApplied: "previouslyExecuted", + }); + } + + // Make sure channel isnt being restored + if (this.restorations.get(initiated.params.channelAddress)) { + return resolve({ + cancelled: false, + value: Result.fail( + new QueuedUpdateError(QueuedUpdateError.reasons.ChannelRestoring, initiated.params, channelState), + ), + successfullyApplied: "executed", + }); + } + try { + const ret = await outbound( + initiated.params, + activeTransfers, + channelState, + this.chainReader, + this.messagingService, + this.externalValidationService, + this.signer, + this.logger, + ); + return resolve({ cancelled: false, value: ret }); + } catch (e) { + return resolve({ + cancelled: false, + value: Result.fail( + new QueuedUpdateError(QueuedUpdateError.reasons.UnhandledPromise, initiated.params, undefined, { + ...jsonifyError(e), + method: "outboundPromise", + }), + ), + }); + } + }); + this.logger.debug( + { + time: Date.now(), + params: initiated.params, + role: "outbound", + channelAddress: initiated.params.channelAddress, + }, + "Beginning race", + ); + const res = (await Promise.race([outboundPromise, cancelPromise])) as { + cancelled: boolean; + value: unknown | Result; + }; + if (res.cancelled) { + this.logger.debug( + { + time: Date.now(), + params: initiated.params, + role: "outbound", + channelAddress: initiated.params.channelAddress, + }, + "Cancelling update", + ); + return undefined; } - aliceIdentifier = channel.aliceIdentifier; - bobIdentifier = channel.bobIdentifier; - } - const isAlice = this.publicIdentifier === aliceIdentifier; - const counterpartyIdentifier = isAlice ? bobIdentifier : aliceIdentifier; - let key: string; - try { - key = await this.lockService.acquireLock(params.channelAddress, isAlice, counterpartyIdentifier); - } catch (e) { - return Result.fail( - new OutboundChannelUpdateError(OutboundChannelUpdateError.reasons.AcquireLockFailed, params, channel, { - lockError: e.message, - }), + const value = res.value as Result; + if (value.isError) { + this.logger.debug( + { + time: Date.now(), + params: initiated.params, + role: "outbound", + channelAddress: initiated.params.channelAddress, + }, + "Update failed", + ); + return res.value as Result; + } + // Save all information returned from the sync result + const { updatedChannel, updatedTransfer, successfullyApplied } = value.getValue(); + this.logger.debug( + { + time: Date.now(), + params: initiated.params, + role: "outbound", + channelAddress: initiated.params.channelAddress, + updatedChannel, + successfullyApplied, + }, + "Update succeeded", ); - } - const outboundRes = await this.lockedOperation(params); - try { - await this.lockService.releaseLock(params.channelAddress, key, isAlice, counterpartyIdentifier); - } catch (e) { - return Result.fail( - new OutboundChannelUpdateError(OutboundChannelUpdateError.reasons.ReleaseLockFailed, params, channel, { - outboundResult: outboundRes.toJson(), - lockError: jsonifyError(e), - }), + const saveRes = await persistChannel(this.storeService, updatedChannel, updatedTransfer); + if (saveRes.isError) { + return Result.fail( + new QueuedUpdateError(QueuedUpdateError.reasons.StoreFailure, initiated.params, updatedChannel, { + method: "saveChannelState", + error: saveRes.getError()!.message, + }), + ); + } + // If the update was not applied, but the channel was synced, return + // undefined so that the proposed update may be re-queued + if (successfullyApplied === "synced") { + return undefined; + } + // All is well, return value from outbound (applies for already executed + // updates as well) + return value; + }; + + // Create a cancellable inbound function to be used when receiving updates + const cancellableInbound: Cancellable = async ( + received: OtherUpdate, + cancel: Promise, + ) => { + // Create a helper to respond to counterparty for errors generated + // on inbound updates + const returnError = async ( + reason: Values, + state?: FullChannelState, + context: any = {}, + error?: QueuedUpdateError, + ): Promise> => { + const e = error ?? new QueuedUpdateError(reason, received.update, state, context); + await this.messagingService.respondWithProtocolError(received.inbox, e); + return Result.fail(e); + }; + + let channelState: FullChannelState | undefined = undefined; + const cancelPromise = new Promise(async (resolve) => { + let ret; + try { + ret = await cancel; + } catch (e) { + // TODO: cancel promise fails? + ret = e; + } + return resolve({ cancelled: true, value: ret }); + }); + const inboundPromise = new Promise(async (resolve) => { + // Pull context from store + const storeRes = await extractContextFromStore( + this.storeService, + received.update.channelAddress, + received.update.id.id, + ); + if (storeRes.isError) { + // Send message with error + return returnError(QueuedUpdateError.reasons.StoreFailure, undefined, { + storeError: storeRes.getError()?.message, + }); + } + // Make sure channel isnt being restored + if (this.restorations.get(received.update.channelAddress)) { + return resolve({ + cancelled: false, + value: Result.fail( + new QueuedUpdateError(QueuedUpdateError.reasons.ChannelRestoring, received.update, channelState), + ), + }); + } + + // NOTE: no need to validate that the update has already been executed + // because that is asserted on sync, where as an initiator you dont have + // that certainty + const stored = storeRes.getValue(); + channelState = stored.channelState; + try { + const ret = await inbound( + received.update, + received.previous, + stored.activeTransfers, + stored.channelState, + this.chainReader, + this.externalValidationService, + this.signer, + this.logger, + ); + return resolve({ cancelled: false, value: ret }); + } catch (e) { + return resolve({ + cancelled: false, + value: Result.fail( + new QueuedUpdateError(QueuedUpdateError.reasons.UnhandledPromise, received.update, undefined, { + ...jsonifyError(e), + method: "inboundPromise", + }), + ), + }); + } + }); + + this.logger.debug( + { + time: Date.now(), + update: received.update, + role: "inbound", + channelAddress: received.update.channelAddress, + }, + "Beginning race", ); - } + const res = (await Promise.race([inboundPromise, cancelPromise])) as { + cancelled: boolean; + value: unknown | Result; + }; - return outboundRes; + if (res.cancelled) { + this.logger.debug( + { + time: Date.now(), + update: received.update, + role: "inbound", + channelAddress: received.update.channelAddress, + }, + "Cancelling update", + ); + // await returnError(QueuedUpdateError.reasons.Cancelled, channelState); + return undefined; + } + const value = res.value as Result; + if (value.isError) { + this.logger.debug( + { + time: Date.now(), + update: received.update, + role: "inbound", + channelAddress: received.update.channelAddress, + }, + "Update failed", + ); + const error = value.getError() as QueuedUpdateError; + const { state } = error.context; + return returnError(error.message, state ?? channelState, undefined, error); + } + // Save the newly signed update to your channel + const { updatedChannel, updatedTransfer } = value.getValue(); + this.logger.debug( + { + time: Date.now(), + update: received.update, + role: "inbound", + channelAddress: received.update.channelAddress, + updatedChannel, + }, + "Update succeeded", + ); + const saveRes = await persistChannel(this.storeService, updatedChannel, updatedTransfer); + if (saveRes.isError) { + return returnError(QueuedUpdateError.reasons.StoreFailure, updatedChannel, { + saveError: saveRes.getError().message, + }); + } + await this.messagingService.respondToProtocolMessage( + received.inbox, + PROTOCOL_VERSION, + updatedChannel.latestUpdate, + (channelState as FullChannelState | undefined)?.latestUpdate, + ); + return value; + }; + const queue = new SerializedQueue( + this.publicIdentifier === aliceIdentifier, + cancellableOutbound, + cancellableInbound, + // TODO: grab nonce without making store call? annoying to store in + // memory, but doable + async () => { + const channel = await this.storeService.getChannelState(channelAddress); + return channel?.nonce ?? 0; + }, + ); + + return queue; } /** @@ -229,7 +516,72 @@ export class Vector implements IVectorProtocol { await this.syncDisputes(); } + // Returns undefined if getChannelState returns undefined (meaning the channel is not found) + private getQueueAsync( + setupAliceIdentifier, + params: UpdateParams, + ): Promise | undefined> { + const channelAddress = params.channelAddress; + const cache = this.queues.get(channelAddress); + if (cache !== undefined) { + return cache; + } + this.logger.debug({ channelAddress }, "Creating queue"); + + let promise = (async () => { + // This is subtle. We use a try/catch and remove the promise from the queue in the + // even of an error. But, without this delay the promise may not be in the queue - + // so it could get added next in a perpetually failing state. + await delay(0); + + let result; + try { + let aliceIdentifier: string; + if (params.type === UpdateType.setup) { + aliceIdentifier = setupAliceIdentifier; + } else { + const channel = await this.storeService.getChannelState(channelAddress); + if (!channel) { + this.queues.delete(channelAddress); + return undefined; + } + aliceIdentifier = channel.aliceIdentifier; + } + result = this.createChannelQueue(channelAddress, aliceIdentifier); + } catch (e) { + this.queues.delete(channelAddress); + throw e; + } + return result; + })(); + + this.queues.set(channelAddress, promise); + return promise; + } + private async setupServices(): Promise { + // TODO: REMOVE THIS! + await this.messagingService.onReceiveLockMessage( + this.publicIdentifier, + async (lockInfo: Result, from: string, inbox: string) => { + if (from === this.publicIdentifier) { + return; + } + const method = "onReceiveProtocolMessage"; + const methodId = getRandomBytes32(); + + this.logger.error({ method, methodId }, "Counterparty using incompatible version"); + await this.messagingService.respondToLockMessage( + inbox, + Result.fail( + new ValidationError(ValidationError.reasons.InvalidProtocolVersion, {} as any, undefined, { + compatible: PROTOCOL_VERSION, + }), + ), + ); + }, + ); + // response to incoming message where we are not the leader // steps: // - validate and save state @@ -238,7 +590,7 @@ export class Vector implements IVectorProtocol { await this.messagingService.onReceiveProtocolMessage( this.publicIdentifier, async ( - msg: Result<{ update: ChannelUpdate; previousUpdate: ChannelUpdate }, ProtocolError>, + msg: Result<{ update: ChannelUpdate; previousUpdate: ChannelUpdate; protocolVersion: string }, ProtocolError>, from: string, inbox: string, ) => { @@ -259,13 +611,28 @@ export class Vector implements IVectorProtocol { const received = msg.getValue(); + // Check the protocol version is compatible + const theirVersion = (received.protocolVersion ?? "0.0.0").split("."); + const ourVersion = PROTOCOL_VERSION.split("."); + if (theirVersion[0] !== ourVersion[0] || theirVersion[1] !== ourVersion[1]) { + this.logger.error({ method, methodId, theirVersion, ourVersion }, "Counterparty using incompatible version"); + await this.messagingService.respondWithProtocolError( + inbox, + new ValidationError(ValidationError.reasons.InvalidProtocolVersion, received.update, undefined, { + responderVersion: ourVersion, + initiatorVersion: theirVersion, + }), + ); + return; + } + // Verify that the message has the correct structure const keys = Object.keys(received); - if (!keys.includes("update") || !keys.includes("previousUpdate")) { + if (!keys.includes("update") || !keys.includes("previousUpdate") || !keys.includes("protocolVersion")) { this.logger.warn({ method, methodId, received: Object.keys(received) }, "Message malformed"); return; } - const receivedError = this.validateParamSchema(received.update, TChannelUpdate); + const receivedError = validateParamSchema(received.update, TChannelUpdate); if (receivedError) { this.logger.warn( { method, methodId, update: received.update, error: jsonifyError(receivedError) }, @@ -273,65 +640,128 @@ export class Vector implements IVectorProtocol { ); return; } - // Previous update may be undefined, but if it exists, validate - const previousError = this.validateParamSchema(received.previousUpdate, TChannelUpdate); - if (previousError && received.previousUpdate) { - this.logger.warn( - { method, methodId, update: received.previousUpdate, error: jsonifyError(previousError) }, - "Received malformed previous update", - ); - return; - } + + // // TODO: why in the world is this causing it to fail + // // Previous update may be undefined, but if it exists, validate + // console.log("******** validating schema"); + // const previousError = validateParamSchema(received.previousUpdate, TChannelUpdate); + // console.log("******** ran validation", previousError); + // if (previousError && received.previousUpdate) { + // this.logger.warn( + // { method, methodId, update: received.previousUpdate, error: jsonifyError(previousError) }, + // "Received malformed previous update", + // ); + // return; + // } if (received.update.fromIdentifier === this.publicIdentifier) { this.logger.debug({ method, methodId }, "Received update from ourselves, doing nothing"); return; } - // validate and save - const inboundRes = await sync.inbound( - received.update, - received.previousUpdate, + // Update has been received and is properly formatted. Before + // applying the update, make sure it is the highest seen nonce + + // If queue does not exist, create it + const queue = await this.getQueueAsync(received.update.fromIdentifier, received.update); + if (queue === undefined) { + return Result.fail(new QueuedUpdateError(QueuedUpdateError.reasons.ChannelNotFound, received.update)); + } + + // Add operation to queue + this.logger.debug({ method, methodId }, "Executing other async"); + const result = await queue.executeOtherAsync({ + update: received.update, + previous: received.previousUpdate, inbox, - this.chainReader, - this.storeService, - this.messagingService, - this.externalValidationService, - this.signer, - this.logger, - ); - if (inboundRes.isError) { - this.logger.warn( - { method, methodId, error: jsonifyError(inboundRes.getError()!) }, - "Failed to apply inbound update", + }); + if (result.isError) { + this.logger.warn({ ...jsonifyError(result.getError()!) }, "Failed to apply inbound update"); + return; + } + const { updatedTransfer, updatedChannel, updatedTransfers } = result.getValue(); + this.evts[ProtocolEventName.CHANNEL_UPDATE_EVENT].post({ + updatedTransfer, + updatedTransfers, + updatedChannelState: updatedChannel, + }); + this.logger.debug({ ...result.toJson() }, "Applied inbound update"); + return; + }, + ); + + // response to restore messages + await this.messagingService.onReceiveRestoreStateMessage( + this.publicIdentifier, + async (restoreData: Result<{ chainId: number }, ProtocolError>, from: string, inbox: string) => { + // If it is from yourself, do nothing + if (from === this.publicIdentifier) { + return; + } + const method = "onReceiveRestoreStateMessage"; + this.logger.debug({ method, data: restoreData.toJson(), inbox }, "Handling restore message"); + + // Received error from counterparty + if (restoreData.isError) { + this.logger.error( + { message: restoreData.getError()!.message, method }, + "Error received from counterparty restore", ); return; } - const { updatedChannel, updatedActiveTransfers, updatedTransfer } = inboundRes.getValue(); + const data = restoreData.getValue(); + const [key] = Object.keys(data ?? []); + if (key !== "chainId") { + this.logger.error({ data }, "Message malformed"); + return; + } - // TODO: more efficient dispute events - // // If it is setup, watch for dispute events in channel - // if (received.update.type === UpdateType.setup) { - // this.logger.info({ channelAddress: updatedChannel.channelAddress }, "Registering channel for dispute events"); - // const registrationRes = await this.chainReader.registerChannel( - // updatedChannel.channelAddress, - // updatedChannel.networkContext.chainId, - // ); - // if (registrationRes.isError) { - // this.logger.warn( - // { ...jsonifyError(registrationRes.getError()!) }, - // "Failed to register channel for dispute watching", - // ); - // } - // } + // Counterparty looking to initiate a restore + let channel: FullChannelState | undefined; + const sendCannotRestoreFromError = (error: Values, context: any = {}) => { + return this.messagingService.respondToRestoreStateMessage( + inbox, + Result.fail(new RestoreError(error, channel!, this.publicIdentifier, { ...context, method })), + ); + }; - this.evts[ProtocolEventName.CHANNEL_UPDATE_EVENT].post({ - updatedChannelState: updatedChannel, - updatedTransfers: updatedActiveTransfers, - updatedTransfer, - }); - this.logger.debug({ method, methodId }, "Method complete"); + // Get info from store to send to counterparty + const { chainId } = data as any; + try { + channel = await this.storeService.getChannelStateByParticipants(this.publicIdentifier, from, chainId); + } catch (e) { + return sendCannotRestoreFromError(RestoreError.reasons.CouldNotGetChannel, { + storeMethod: "getChannelStateByParticipants", + chainId, + identifiers: [this.publicIdentifier, from], + }); + } + if (!channel) { + return sendCannotRestoreFromError(RestoreError.reasons.ChannelNotFound, { chainId }); + } + let activeTransfers: FullTransferState[]; + try { + activeTransfers = await this.storeService.getActiveTransfers(channel.channelAddress); + } catch (e) { + return sendCannotRestoreFromError(RestoreError.reasons.CouldNotGetActiveTransfers, { + storeMethod: "getActiveTransfers", + chainId, + channelAddress: channel.channelAddress, + }); + } + + // Send info to counterparty + this.logger.info( + { + method, + channel: channel.channelAddress, + nonce: channel.nonce, + activeTransfers: activeTransfers.map((a) => a.transferId), + }, + "Sending counterparty state to sync", + ); + await this.messagingService.respondToRestoreStateMessage(inbox, Result.ok({ channel, activeTransfers })); }, ); @@ -347,14 +777,12 @@ export class Vector implements IVectorProtocol { return this; } - private validateParamSchema(params: any, schema: any): undefined | OutboundChannelUpdateError { - const error = validateSchema(params, schema); - if (error) { - return new OutboundChannelUpdateError(OutboundChannelUpdateError.reasons.InvalidParams, params, undefined, { - paramsError: error, - }); - } - return undefined; + private async generateIdentifier(): Promise { + const id = uuidV4(); + return { + id, + signature: await this.signer.signMessage(id), + }; } /* @@ -370,17 +798,19 @@ export class Vector implements IVectorProtocol { // as well as contextual validation (i.e. do I have sufficient funds to // create this transfer, is the channel in dispute, etc.) - public async setup(params: ProtocolParams.Setup): Promise> { + public async setup(params: ProtocolParams.Setup): Promise> { const method = "setup"; const methodId = getRandomBytes32(); this.logger.debug({ method, methodId }, "Method start"); // Validate all parameters - const error = this.validateParamSchema(params, ProtocolParams.SetupSchema); + const error = validateParamSchema(params, ProtocolParams.SetupSchema); if (error) { this.logger.error({ method, methodId, params, error: jsonifyError(error) }); return Result.fail(error); } + const id = await this.generateIdentifier(); + const create2Res = await getCreate2MultisigAddress( this.publicIdentifier, params.counterpartyIdentifier, @@ -390,9 +820,9 @@ export class Vector implements IVectorProtocol { ); if (create2Res.isError) { return Result.fail( - new OutboundChannelUpdateError( - OutboundChannelUpdateError.reasons.Create2Failed, - { details: params, channelAddress: "", type: UpdateType.setup }, + new QueuedUpdateError( + QueuedUpdateError.reasons.Create2Failed, + { details: params, channelAddress: "", type: UpdateType.setup, id }, undefined, { create2Error: create2Res.getError()?.message, @@ -407,6 +837,7 @@ export class Vector implements IVectorProtocol { channelAddress, details: params, type: UpdateType.setup, + id, }; const returnVal = await this.executeUpdate(updateParams); @@ -437,12 +868,12 @@ export class Vector implements IVectorProtocol { } // Adds a deposit that has *already occurred* onchain into the multisig - public async deposit(params: ProtocolParams.Deposit): Promise> { + public async deposit(params: ProtocolParams.Deposit): Promise> { const method = "deposit"; const methodId = getRandomBytes32(); this.logger.debug({ method, methodId }, "Method start"); // Validate all input - const error = this.validateParamSchema(params, ProtocolParams.DepositSchema); + const error = validateParamSchema(params, ProtocolParams.DepositSchema); if (error) { return Result.fail(error); } @@ -452,6 +883,7 @@ export class Vector implements IVectorProtocol { channelAddress: params.channelAddress, type: UpdateType.deposit, details: params, + id: await this.generateIdentifier(), }; const returnVal = await this.executeUpdate(updateParams); @@ -466,12 +898,12 @@ export class Vector implements IVectorProtocol { return returnVal; } - public async create(params: ProtocolParams.Create): Promise> { + public async create(params: ProtocolParams.Create): Promise> { const method = "create"; const methodId = getRandomBytes32(); this.logger.debug({ method, methodId }, "Method start"); // Validate all input - const error = this.validateParamSchema(params, ProtocolParams.CreateSchema); + const error = validateParamSchema(params, ProtocolParams.CreateSchema); if (error) { return Result.fail(error); } @@ -481,6 +913,7 @@ export class Vector implements IVectorProtocol { channelAddress: params.channelAddress, type: UpdateType.create, details: params, + id: await this.generateIdentifier(), }; const returnVal = await this.executeUpdate(updateParams); @@ -495,12 +928,12 @@ export class Vector implements IVectorProtocol { return returnVal; } - public async resolve(params: ProtocolParams.Resolve): Promise> { + public async resolve(params: ProtocolParams.Resolve): Promise> { const method = "resolve"; const methodId = getRandomBytes32(); this.logger.debug({ method, methodId }, "Method start"); // Validate all input - const error = this.validateParamSchema(params, ProtocolParams.ResolveSchema); + const error = validateParamSchema(params, ProtocolParams.ResolveSchema); if (error) { return Result.fail(error); } @@ -510,6 +943,7 @@ export class Vector implements IVectorProtocol { channelAddress: params.channelAddress, type: UpdateType.resolve, details: params, + id: await this.generateIdentifier(), }; const returnVal = await this.executeUpdate(updateParams); @@ -524,6 +958,128 @@ export class Vector implements IVectorProtocol { return returnVal; } + public async restoreState( + params: ProtocolParams.Restore, + ): Promise> { + const method = "restoreState"; + const methodId = getRandomBytes32(); + this.logger.debug({ method, methodId }, "Method start"); + // Validate all input + const error = validateParamSchema(params, ProtocolParams.RestoreSchema); + if (error) { + return Result.fail(error); + } + + // Send message to counterparty, they will grab lock and + // return information under lock, initiator will update channel, + // then send confirmation message to counterparty, who will release the lock + const { chainId, counterpartyIdentifier } = params; + const restoreDataRes = await this.messagingService.sendRestoreStateMessage( + Result.ok({ chainId }), + counterpartyIdentifier, + this.signer.publicIdentifier, + ); + if (restoreDataRes.isError) { + return Result.fail(restoreDataRes.getError() as RestoreError); + } + + const { channel, activeTransfers } = restoreDataRes.getValue() ?? ({} as any); + + // Create helper to generate error + const generateRestoreError = ( + error: Values, + context: any = {}, + ): Result => { + // handle error by returning it to counterparty && returning result + const err = new RestoreError(error, channel, this.publicIdentifier, { + ...context, + method, + params, + }); + channel && this.restorations.set(channel.channelAddress, false); + return Result.fail(err); + }; + + // Verify data exists + if (!channel || !activeTransfers) { + return generateRestoreError(RestoreError.reasons.NoData); + } + + // Set restoration for channel to true + this.restorations.set(channel.channelAddress, true); + + // Verify channel address is same as calculated + const counterparty = getSignerAddressFromPublicIdentifier(counterpartyIdentifier); + const calculated = await this.chainReader.getChannelAddress( + channel.alice === this.signer.address ? this.signer.address : counterparty, + channel.bob === this.signer.address ? this.signer.address : counterparty, + channel.networkContext.channelFactoryAddress, + chainId, + ); + if (calculated.isError) { + return generateRestoreError(RestoreError.reasons.GetChannelAddressFailed, { + getChannelAddressError: jsonifyError(calculated.getError()!), + }); + } + if (calculated.getValue() !== channel.channelAddress) { + return generateRestoreError(RestoreError.reasons.InvalidChannelAddress, { + calculated: calculated.getValue(), + }); + } + + // Verify signatures on latest update + const sigRes = await validateChannelSignatures( + channel, + channel.latestUpdate.aliceSignature, + channel.latestUpdate.bobSignature, + "both", + ); + if (sigRes.isError) { + return generateRestoreError(RestoreError.reasons.InvalidSignatures, { + recoveryError: sigRes.getError()!.message, + }); + } + + // Verify transfers match merkleRoot + const root = generateMerkleRoot(activeTransfers); + if (root !== channel.merkleRoot) { + return generateRestoreError(RestoreError.reasons.InvalidMerkleRoot, { + calculated: root, + merkleRoot: channel.merkleRoot, + activeTransfers: activeTransfers.map((t) => t.transferId), + }); + } + + // Verify nothing with a sync-able nonce exists in store + const existing = await this.getChannelState(channel.channelAddress); + const nonce = existing?.nonce ?? 0; + const next = getNextNonceForUpdate(nonce, channel.latestUpdate.fromIdentifier === channel.aliceIdentifier); + if (next === channel.nonce && channel.latestUpdate.type !== UpdateType.setup) { + return generateRestoreError(RestoreError.reasons.SyncableState, { + existing: nonce, + toRestore: channel.nonce, + }); + } + if (nonce >= channel.nonce) { + return generateRestoreError(RestoreError.reasons.SyncableState, { + existing: nonce, + toRestore: channel.nonce, + }); + } + + // Save channel + try { + await this.storeService.saveChannelStateAndTransfers(channel, activeTransfers); + } catch (e) { + return generateRestoreError(RestoreError.reasons.SaveChannelFailed, { + saveChannelStateAndTransfersError: e.message, + }); + } + + this.restorations.set(channel.channelAddress, false); + return Result.ok(channel); + } + /////////////////////////////////// // STORE METHODS public async getChannelState(channelAddress: string): Promise { diff --git a/modules/router/ops/webpack.config.js b/modules/router/ops/webpack.config.js index d09d33299..6f02f70c1 100644 --- a/modules/router/ops/webpack.config.js +++ b/modules/router/ops/webpack.config.js @@ -52,6 +52,11 @@ module.exports = { }, }, }, + { + test: /\.wasm$/, + type: "javascript/auto", + use: "wasm-loader", + }, ], }, @@ -62,6 +67,10 @@ module.exports = { from: path.join(__dirname, "../node_modules/@connext/vector-contracts/dist/pure-evm_bg.wasm"), to: path.join(__dirname, "../dist/pure-evm_bg.wasm"), }, + { + from: path.join(__dirname, "../../../node_modules/@connext/vector-merkle-tree/dist/node/index_bg.wasm"), + to: path.join(__dirname, "../dist/index_bg.wasm"), + }, { from: path.join(__dirname, "../prisma-postgres"), to: path.join(__dirname, "../dist/prisma-postgres"), diff --git a/modules/router/package.json b/modules/router/package.json index 08bc1b087..e9c1f813e 100644 --- a/modules/router/package.json +++ b/modules/router/package.json @@ -14,10 +14,11 @@ "author": "", "license": "ISC", "dependencies": { - "@connext/vector-contracts": "0.2.5-beta.18", - "@connext/vector-engine": "0.2.5-beta.18", - "@connext/vector-types": "0.2.5-beta.18", - "@connext/vector-utils": "0.2.5-beta.18", + "@connext/vector-merkle-tree": "0.1.4", + "@connext/vector-contracts": "0.3.0-beta.2", + "@connext/vector-engine": "0.3.0-beta.2", + "@connext/vector-types": "0.3.0-beta.2", + "@connext/vector-utils": "0.3.0-beta.2", "@ethersproject/abi": "5.2.0", "@ethersproject/address": "5.2.0", "@ethersproject/bignumber": "5.2.0", diff --git a/modules/router/src/forwarding.ts b/modules/router/src/forwarding.ts index a24941781..41cf537e5 100644 --- a/modules/router/src/forwarding.ts +++ b/modules/router/src/forwarding.ts @@ -290,7 +290,7 @@ export async function forwardTransferCreation( } // Pull the receiver channel from db - const recipientChannelRes = await nodeService.getStateChannelByParticipants({ + let recipientChannelRes = await nodeService.getStateChannelByParticipants({ publicIdentifier: routerPublicIdentifier, counterparty: recipientIdentifier, chainId: recipientChainId, @@ -308,18 +308,44 @@ export async function forwardTransferCreation( }, ); } - const recipientChannel = recipientChannelRes.getValue() as FullChannelState | undefined; + let recipientChannel = recipientChannelRes.getValue() as FullChannelState | undefined; if (!recipientChannel) { - return cancelSenderTransferAndReturnError( - routingId, - senderTransfer, - ForwardTransferCreationError.reasons.RecipientChannelNotFound, - "", - { - participants: [routerPublicIdentifier, recipientIdentifier], - chainId: recipientChainId, - }, - ); + // create receiver channel if does not exist + const setupRes = await nodeService.setup({ + chainId: recipientChainId, + counterpartyIdentifier: recipientIdentifier, + }); + if (setupRes.isError) { + return cancelSenderTransferAndReturnError( + routingId, + senderTransfer, + ForwardTransferCreationError.reasons.RecipientChannelNotFound, + "", + { + setupError: setupRes.getError()!.toJson(), + recipientChainId, + recipientIdentifier, + }, + ); + } + logger.info({ recipientChannel: setupRes.getValue(), methodId, method }, "Created channel for receiver"); + recipientChannelRes = await nodeService.getStateChannel({ + channelAddress: setupRes.getValue().channelAddress, + }); + if (recipientChannelRes.isError) { + return cancelSenderTransferAndReturnError( + routingId, + senderTransfer, + ForwardTransferCreationError.reasons.RecipientChannelNotFound, + "", + { + storeError: recipientChannelRes.getError()!.toJson(), + recipientChainId, + recipientIdentifier, + }, + ); + } + recipientChannel = recipientChannelRes.getValue() as FullChannelState; } // Below, we figure out the correct params needed for the receiver's channel. diff --git a/modules/router/src/index.ts b/modules/router/src/index.ts index b04f94c3e..c88b769ba 100644 --- a/modules/router/src/index.ts +++ b/modules/router/src/index.ts @@ -26,6 +26,7 @@ import { ChannelDefundedPayload, ChannelDisputedPayload, ConditionalTransferRoutingCompletePayload, + RunAuctionPayload, } from "@connext/vector-types"; import { collectDefaultMetrics, register } from "prom-client"; import { Wallet } from "ethers"; @@ -60,6 +61,7 @@ const channelDisputedPath = "/channel-disputed"; const channelDefundedPath = "/channel-defunded"; const transferDisputedPath = "/transfer-disputed"; const transferDefundedPath = "/transfer-defunded"; +const runAuctionPath = "/run-auction"; const evts: EventCallbackConfig = { [EngineEvents.IS_ALIVE]: { evt: Evt.create(), @@ -133,6 +135,10 @@ const evts: EventCallbackConfig = { evt: Evt.create(), url: `${routerBase}${transferDefundedPath}`, }, + [EngineEvents.RUN_AUCTION_EVENT]: { + evt: Evt.create(), + url: `${routerBase}${runAuctionPath}`, + }, }; export const signer = new ChannelSigner(Wallet.fromMnemonic(config.mnemonic).privateKey); @@ -328,6 +334,11 @@ server.post(transferDefundedPath, async (request, response) => { return response.status(200).send({ message: "success" }); }); +server.post(runAuctionPath, async (request, response) => { + evts[EngineEvents.RUN_AUCTION_EVENT].evt!.post(request.body as RunAuctionPayload & { publicIdentifier: string }); + return response.status(200).send({ message: "success" }); +}); + server.listen(config.routerUrl.split(":").pop() ?? 8000, "0.0.0.0", (err, address) => { if (err) { console.error(err); diff --git a/modules/router/src/listener.ts b/modules/router/src/listener.ts index 828b0cd2e..3238b675c 100644 --- a/modules/router/src/listener.ts +++ b/modules/router/src/listener.ts @@ -762,12 +762,14 @@ export async function setupListeners( BigNumber.from(amount), receiveExactAmount, assetId, - senderChannel, + senderChannel.networkContext.chainId, recipientAssetId, - recipientChannel, + recipientChannel.networkContext.chainId, chainReader, routerSigner.publicIdentifier, logger, + senderChannel, + recipientChannel, ); if (feeRes.isError) { logger.error({ error: feeRes.getError() }, "Error in calculateFeeAmount"); @@ -822,6 +824,193 @@ export async function setupListeners( ); } }); + // TODO Refactoring, repeated code + await messagingService.onReceiveStartAuction(routerSigner.publicIdentifier, async (request, from, inbox) => { + const method = "onReceiveStartAuction"; + const methodId = getRandomBytes32(); + logger.debug({ method, methodId }, "Method started"); + if (request.isError) { + logger.error( + { error: request.getError()!.toJson(), from, method, methodId }, + "Received error, shouldn't happen!", + ); + return; + } + const { + amount, + assetId, + chainId, + recipient: _recipient, + recipientChainId: _recipientChainId, + recipientAssetId: _recipientAssetId, + // receiveExactAmount: _receiveExactAmount, + } = request.getValue(); + + const recipient = _recipient ?? routerSigner.publicIdentifier; + const recipientChainId = _recipientChainId ?? chainId; + const recipientAssetId = _recipientAssetId ?? assetId; + // const receiveExactAmount = _receiveExactAmount ?? false; + + const isSwap = recipientChainId !== chainId || recipientAssetId !== assetId; + const supported = isSwap + ? getMatchingSwap(assetId, chainId, recipientAssetId, recipientChainId) + : getRebalanceProfile(recipientChainId, recipientAssetId); + + if (supported.isError) { + await messagingService.respondToAuctionMessage( + inbox, + Result.fail( + new QuoteError(QuoteError.reasons.TransferNotSupported, { + supportedError: jsonifyError(supported.getError()!), + recipient, + recipientChainId, + recipientAssetId, + assetId, + chainId, + sender: from, + }), + ), + ); + return; + } + + const supportedChains = Object.keys(config.chainProviders); + if (!supportedChains.includes(chainId.toString()) || !supportedChains.includes(recipientChainId.toString())) { + // recipient or sender chain not supported + await messagingService.respondToAuctionMessage( + inbox, + Result.fail( + new QuoteError(QuoteError.reasons.ChainNotSupported, { + supportedChains, + recipient, + recipientChainId, + recipientAssetId, + assetId, + chainId, + sender: from, + }), + ), + ); + return; + } + + const [senderChannelRes, recipientChannelRes] = await Promise.all([ + nodeService.getStateChannelByParticipants({ counterparty: from, chainId }), + nodeService.getStateChannelByParticipants({ + chainId: recipientChainId, + counterparty: recipient === routerSigner.publicIdentifier ? from : recipient, + }), + ]); + + let senderChannel: FullChannelState | undefined; + let recipientChannel: FullChannelState | undefined; + if (senderChannelRes.isError || recipientChannelRes.isError) { + // if channel doesn't exist, dont error + if ( + (senderChannelRes.isError && + !senderChannelRes.getError()?.message.toLowerCase().includes("channel not found")) || + (recipientChannelRes.isError && + !recipientChannelRes.getError()?.message.toLowerCase().includes("channel not found")) + ) { + // return error to counterparty + await messagingService.respondToAuctionMessage( + inbox, + Result.fail( + new QuoteError(QuoteError.reasons.CouldNotGetChannel, { + senderChannelError: senderChannelRes.isError ? jsonifyError(senderChannelRes.getError()!) : undefined, + recipientChannelError: recipientChannelRes.isError + ? jsonifyError(recipientChannelRes.getError()!) + : undefined, + recipient, + recipientChainId, + recipientAssetId, + assetId, + chainId, + sender: from, + }), + ), + ); + return; + } + } else { + senderChannel = senderChannelRes.getValue() as FullChannelState; + recipientChannel = recipientChannelRes.getValue() as FullChannelState; + } + + const feeRes = await calculateFeeAmount( + BigNumber.from(amount), + false, // receive exact amount, to be reviewed + assetId, + chainId, + recipientAssetId, + recipientChainId, + chainReader, + routerSigner.publicIdentifier, + logger, + senderChannel, + recipientChannel, + ); + if (feeRes.isError) { + logger.error({ error: feeRes.getError() }, "Error in calculateFeeAmount"); + await messagingService.respondToAuctionMessage( + inbox, + Result.fail( + new QuoteError(QuoteError.reasons.CouldNotGetFee, { + feeError: jsonifyError(feeRes.getError()!), + recipient, + recipientChainId, + recipientAssetId, + assetId, + chainId, + sender: from, + }), + ), + ); + return; + } + const { fee, amount: quoteAmount } = feeRes.getValue(); + const quote = { + assetId, + amount: quoteAmount.toString(), + chainId, + routerIdentifier: routerSigner.publicIdentifier, + recipient, + recipientChainId, + recipientAssetId, + fee: fee.toString(), + expiry: (Date.now() + (getConfig().feeQuoteExpiry ?? DEFAULT_FEE_EXPIRY)).toString(), // valid for next 2 blocks + }; + + try { + const signature = await routerSigner.signMessage(quote.toString()); + await messagingService.respondToAuctionMessage( + inbox, + Result.ok({ + quote: { ...quote, signature }, + routerPublicIdentifier: quote.routerIdentifier, + swapRate: "1", + totalFee: quote.fee, + }), + ); + } catch (e) { + await messagingService.respondToAuctionMessage( + inbox, + Result.fail( + new QuoteError(QuoteError.reasons.CouldNotSignQuote, { + error: jsonifyError(e), + recipient, + recipientChainId, + recipientAssetId, + assetId, + chainId, + sender: from, + fee: quote.fee, + expiry: quote.expiry, + }), + ), + ); + } + }); logger.debug({ method, methodId }, "Method complete"); } diff --git a/modules/router/src/services/fees.ts b/modules/router/src/services/fees.ts index 737614c0e..e682edbdd 100644 --- a/modules/router/src/services/fees.ts +++ b/modules/router/src/services/fees.ts @@ -12,6 +12,7 @@ import { getBalanceForAssetId, getParticipant, getRandomBytes32, + getSignerAddressFromPublicIdentifier, TESTNETS_WITH_FEES, toWad, } from "@connext/vector-utils"; @@ -32,12 +33,15 @@ export const calculateFeeAmount = async ( transferAmount: BigNumber, receiveExactAmount: boolean, fromAssetId: string, - fromChannel: FullChannelState, + fromChainId: number, toAssetId: string, - toChannel: FullChannelState, + toChainId: number, ethReader: IVectorChainReader, routerPublicIdentifier: string, logger: BaseLogger, + // channels are optional, if not provided automatically assume fee needs to include channel creations + fromChannel?: FullChannelState, + toChannel?: FullChannelState, ): Promise> => { const method = "calculateFeeAmount"; const methodId = getRandomBytes32(); @@ -47,25 +51,22 @@ export const calculateFeeAmount = async ( methodId, startingAmount: transferAmount.toString(), fromAssetId, - fromChainId: fromChannel.networkContext.chainId, - fromChannel: fromChannel.channelAddress, - toChainId: toChannel.networkContext.chainId, + fromChainId, + fromChannel: fromChannel?.channelAddress, + toChainId, toAssetId, - toChannel: toChannel.channelAddress, + toChannel: toChannel?.channelAddress, }, "Method start", ); - const fromChainId = fromChannel.networkContext.chainId; - const toChainId = toChannel.networkContext.chainId; - const onSwapGivenInRes = await onSwapGivenIn( transferAmount, fromAssetId, fromChainId, toAssetId, toChainId, - fromChannel.alice, + fromChannel?.alice ?? getSignerAddressFromPublicIdentifier(routerPublicIdentifier), // assume alice if channel does not exist ethReader, logger, ); @@ -84,7 +85,7 @@ export const calculateFeeAmount = async ( const amountOut = onSwapGivenInRes.getValue().amountOut; // If recipient is router, i.e. fromChannel === toChannel, then the // fee amount is 0 because no fees are taken without forwarding - if (toChannel.channelAddress === fromChannel.channelAddress) { + if ((toChannel || fromChannel) && toChannel?.channelAddress === fromChannel?.channelAddress) { return Result.ok({ fee: Zero, amount: amountOut }); } @@ -166,13 +167,15 @@ export const calculateFeeAmount = async ( // Calculate gas fees for transfer const gasFeesRes = await calculateEstimatedGasFee( transferAmount, // in fromAsset - toAssetId, fromAssetId, - fromChannel, - toChannel, + fromChainId, + toAssetId, + toChainId, ethReader, routerPublicIdentifier, logger, + fromChannel, + toChannel, ); if (gasFeesRes.isError) { return Result.fail(gasFeesRes.getError()!); @@ -180,8 +183,8 @@ export const calculateFeeAmount = async ( const gasFees = gasFeesRes.getValue(); logger.debug( { - reclaimGasFees: gasFees[fromChannel.channelAddress].toString(), - collateralizeGasFees: gasFees[toChannel.channelAddress].toString(), + reclaimGasFees: gasFees[0].toString(), + collateralizeGasFees: gasFees[1].toString(), }, "Calculated gas fees", ); @@ -192,10 +195,10 @@ export const calculateFeeAmount = async ( let toAssetDecimals: number | undefined = undefined; let baseAssetToChainDecimals: number | undefined = undefined; try { - fromAssetDecimals = await getDecimals(fromChannel.networkContext.chainId.toString(), fromAssetId); - baseAssetFromChainDecimals = await getDecimals(fromChannel.networkContext.chainId.toString(), AddressZero); - toAssetDecimals = await getDecimals(toChannel.networkContext.chainId.toString(), toAssetId); - baseAssetToChainDecimals = await getDecimals(toChannel.networkContext.chainId.toString(), AddressZero); + fromAssetDecimals = await getDecimals(fromChainId.toString(), fromAssetId); + baseAssetFromChainDecimals = await getDecimals(fromChainId.toString(), AddressZero); + toAssetDecimals = await getDecimals(toChainId.toString(), toAssetId); + baseAssetToChainDecimals = await getDecimals(toChainId.toString(), AddressZero); } catch (e) { logger.error( { @@ -228,7 +231,7 @@ export const calculateFeeAmount = async ( const normalizedReclaimFromAsset = fromChainId === 1 || TESTNETS_WITH_FEES.includes(fromChainId) // fromAsset MUST be on mainnet or hardcoded ? await normalizeGasFees( - gasFees[fromChannel.channelAddress], + gasFees[0], // fromChannel fees baseAssetFromChainDecimals, fromAssetId, fromAssetDecimals, @@ -241,7 +244,7 @@ export const calculateFeeAmount = async ( const normalizedCollateralToAsset = toChainId === 1 || TESTNETS_WITH_FEES.includes(toChainId) // toAsset MUST be on mainnet or hardcoded ? await normalizeGasFees( - gasFees[toChannel.channelAddress], + gasFees[1], // toChannel fees baseAssetToChainDecimals, toAssetId, toAssetDecimals, @@ -334,14 +337,16 @@ export const calculateFeeAmount = async ( // reclaim fees export const calculateEstimatedGasFee = async ( amountToSend: BigNumber, // in fromAsset - toAssetId: string, fromAssetId: string, - fromChannel: FullChannelState, - toChannel: FullChannelState, + fromChainId: number, + toAssetId: string, + toChainId: number, ethReader: IVectorChainReader, routerPublicIdentifier: string, logger: BaseLogger, -): Promise> => { + fromChannel?: FullChannelState, + toChannel?: FullChannelState, +): Promise> => { const method = "calculateEstimatedGasFee"; const methodId = getRandomBytes32(); logger.debug( @@ -349,79 +354,93 @@ export const calculateEstimatedGasFee = async ( method, methodId, amountToSend: amountToSend.toString(), - toChannel: toChannel.channelAddress, + toChannel: toChannel?.channelAddress, }, "Method start", ); - // the sender channel will have the following possible actions based on the - // rebalance profile: - // (1) IFF current balance + transfer amount > reclaimThreshold, reclaim - // (2) IFF current balance + transfer amount < collateralThreshold, - // collateralize - const participantFromChannel = getParticipant(fromChannel, routerPublicIdentifier); - if (!participantFromChannel) { - return Result.fail( - new FeeError(FeeError.reasons.ChannelError, { - message: "Not in channel", - publicIdentifier: routerPublicIdentifier, - alice: fromChannel.aliceIdentifier, - bob: fromChannel.bobIdentifier, - channelAddress: fromChannel.channelAddress, - }), - ); - } - // Determine final balance (assuming successful transfer resolution) - const finalFromBalance = amountToSend.add(getBalanceForAssetId(fromChannel, fromAssetId, participantFromChannel)); + let fromChannelFee = Zero; // start with no actions - // Actions in channel will depend on contract being deployed, so get that - const fromChannelCode = await ethReader.getCode(fromChannel.channelAddress, fromChannel.networkContext.chainId); - if (fromChannelCode.isError) { - return Result.fail( - new FeeError(FeeError.reasons.ChainError, { - fromChainId: fromChannel.networkContext.chainId, - fromChannel: fromChannel.channelAddress, - getCodeError: jsonifyError(fromChannelCode.getError()!), - }), - ); - } + // the sender channel will have the following possible actions: + // (1) IFF current balance + transfer amount > reclaimThreshold, reclaim + // (2) IFF current balance + transfer amount < collateralThreshold, collateralize + // (3) IFF channel has not been deployed, deploy // Get the rebalance profile - const rebalanceFromProfile = getRebalanceProfile(fromChannel.networkContext.chainId, fromAssetId); + const rebalanceFromProfile = getRebalanceProfile(fromChainId, fromAssetId); if (rebalanceFromProfile.isError) { return Result.fail( new FeeError(FeeError.reasons.ConfigError, { message: "Failed to get rebalance profile", assetId: fromAssetId, - chainId: fromChannel.networkContext.chainId, + chainId: fromChainId, error: jsonifyError(rebalanceFromProfile.getError()!), }), ); } - const fromProfile = rebalanceFromProfile.getValue(); - let fromChannelFee = Zero; // start with no actions - if (finalFromBalance.gt(fromProfile.reclaimThreshold)) { - // There will be a post-resolution reclaim of funds - fromChannelFee = - fromChannelCode.getValue() === "0x" - ? GAS_ESTIMATES.createChannel.add(SIMPLE_WITHDRAWAL_GAS_ESTIMATE) - : SIMPLE_WITHDRAWAL_GAS_ESTIMATE; - } else if (finalFromBalance.lt(fromProfile.collateralizeThreshold)) { - // There will be a post-resolution sender collateralization - // gas estimates are participant sensitive, so this is safe to do - fromChannelFee = - participantFromChannel === "alice" && fromChannelCode.getValue() === "0x" - ? GAS_ESTIMATES.createChannelAndDepositAlice - : participantFromChannel === "alice" - ? GAS_ESTIMATES.depositAlice - : GAS_ESTIMATES.depositBob; + if (!fromChannel) { + // if no fromChannel, assume all actions take place + if (amountToSend.gt(fromProfile.reclaimThreshold)) { + // There will be a post-resolution reclaim of funds + fromChannelFee = GAS_ESTIMATES.createChannel.add(SIMPLE_WITHDRAWAL_GAS_ESTIMATE); + } else if (amountToSend.lt(fromProfile.collateralizeThreshold)) { + // There will be a post-resolution sender collateralization + // NOTE: we are assuming router is Alice for a non-created channel because we have no way to know + // if they are not. this is a safe assumption since likely multihop will not be there for a while + // revisit this if we decide to implement multihop + fromChannelFee = GAS_ESTIMATES.createChannelAndDepositAlice; + } + } else { + const participantFromChannel = getParticipant(fromChannel, routerPublicIdentifier); + if (!participantFromChannel) { + return Result.fail( + new FeeError(FeeError.reasons.ChannelError, { + message: "Not in channel", + publicIdentifier: routerPublicIdentifier, + alice: fromChannel.aliceIdentifier, + bob: fromChannel.bobIdentifier, + channelAddress: fromChannel.channelAddress, + }), + ); + } + // Determine final balance (assuming successful transfer resolution) + const finalFromBalance = amountToSend.add(getBalanceForAssetId(fromChannel, fromAssetId, participantFromChannel)); + + // Actions in channel will depend on contract being deployed, so get that + const fromChannelCode = await ethReader.getCode(fromChannel.channelAddress, fromChannel.networkContext.chainId); + if (fromChannelCode.isError) { + return Result.fail( + new FeeError(FeeError.reasons.ChainError, { + fromChainId: fromChannel.networkContext.chainId, + fromChannel: fromChannel.channelAddress, + getCodeError: jsonifyError(fromChannelCode.getError()!), + }), + ); + } + + if (finalFromBalance.gt(fromProfile.reclaimThreshold)) { + // There will be a post-resolution reclaim of funds + fromChannelFee = + fromChannelCode.getValue() === "0x" + ? GAS_ESTIMATES.createChannel.add(SIMPLE_WITHDRAWAL_GAS_ESTIMATE) + : SIMPLE_WITHDRAWAL_GAS_ESTIMATE; + } else if (finalFromBalance.lt(fromProfile.collateralizeThreshold)) { + // There will be a post-resolution sender collateralization + // gas estimates are participant sensitive, so this is safe to do + fromChannelFee = + participantFromChannel === "alice" && fromChannelCode.getValue() === "0x" + ? GAS_ESTIMATES.createChannelAndDepositAlice + : participantFromChannel === "alice" + ? GAS_ESTIMATES.depositAlice + : GAS_ESTIMATES.depositBob; + } } // when forwarding a transfer, the only immediate costs on the receiver-side // are the ones needed to properly collateralize the transfer - // there are several conditions that would effect the collateral costs + // there are several conditions that would affect the collateral costs // (1) channel has sufficient collateral: none // (2) participant == alice && contract not deployed: createChannelAndDeposit // (3) participant == alice && contract deployed: depositAlice @@ -429,69 +448,111 @@ export const calculateEstimatedGasFee = async ( // not need to be created for a deposit to be recognized offchain) // (5) participant == bob && contract deployed: depositBob - const participantToChannel = getParticipant(toChannel, routerPublicIdentifier); - if (!participantToChannel) { - return Result.fail( - new FeeError(FeeError.reasons.ChannelError, { - message: "Not in channel", - publicIdentifier: routerPublicIdentifier, - alice: toChannel.aliceIdentifier, - bob: toChannel.bobIdentifier, - channelAddress: toChannel.channelAddress, - }), - ); - } - const routerBalance = getBalanceForAssetId(toChannel, toAssetId, participantToChannel); - // get the amount you would send - const isSwap = fromAssetId !== toAssetId || fromChannel.networkContext.chainId !== toChannel.networkContext.chainId; - const converted = isSwap - ? await getSwappedAmount( - amountToSend.toString(), - fromAssetId, - fromChannel.networkContext.chainId, - toAssetId, - toChannel.networkContext.chainId, - ) - : Result.ok(amountToSend.toString()); - if (converted.isError) { + // reclaimation cases: + // (1) channel balance > reclaimThreshold after transfer: withdraw + + // Get the rebalance profile + const rebalanceToProfile = getRebalanceProfile(toChainId, toAssetId); + if (rebalanceToProfile.isError) { return Result.fail( - new FeeError(FeeError.reasons.ConversionError, { - swapError: jsonifyError(converted.getError()!), + new FeeError(FeeError.reasons.ConfigError, { + message: "Failed to get rebalance profile", + assetId: toAssetId, + chainId: toChainId, + error: jsonifyError(rebalanceToProfile.getError()!), }), ); } - if (BigNumber.from(routerBalance).gte(converted.getValue())) { - // channel has balance, no extra gas required to facilitate transfer + const toProfile = rebalanceToProfile.getValue(); + + if (!toChannel) { + // if no channel exists, we need to account for the full channel deployment always + // NOTE: same assumption as above for alice + return Result.ok([fromChannelFee, GAS_ESTIMATES.createChannelAndDepositAlice]); + } else { + const participantToChannel = getParticipant(toChannel, routerPublicIdentifier); + if (!participantToChannel) { + return Result.fail( + new FeeError(FeeError.reasons.ChannelError, { + message: "Not in channel", + publicIdentifier: routerPublicIdentifier, + alice: toChannel.aliceIdentifier, + bob: toChannel.bobIdentifier, + channelAddress: toChannel.channelAddress, + }), + ); + } + const routerBalance = getBalanceForAssetId(toChannel, toAssetId, participantToChannel); + // get the amount you would send + const isSwap = fromAssetId !== toAssetId || fromChainId !== toChainId; + const converted = isSwap + ? await getSwappedAmount(amountToSend.toString(), fromAssetId, fromChainId, toAssetId, toChainId) + : Result.ok(amountToSend.toString()); + if (converted.isError) { + return Result.fail( + new FeeError(FeeError.reasons.ConversionError, { + swapError: jsonifyError(converted.getError()!), + }), + ); + } + + if (BigNumber.from(routerBalance).gte(converted.getValue())) { + // channel has balance, no extra gas required to facilitate transfer + logger.info( + { method, methodId, routerBalance: routerBalance.toString(), amountToSend: amountToSend.toString() }, + "Channel is collateralized", + ); + logger.debug( + { + method, + methodId, + }, + "Method complete", + ); + // check for reclaim, reclaim if end balance after in-channel transger + let toChannelFee = Zero; + if (BigNumber.from(routerBalance).sub(converted.getValue()).gt(toProfile.reclaimThreshold)) { + toChannelFee = toChannelFee.add(GAS_ESTIMATES.withdraw); + } + return Result.ok([fromChannelFee, toChannelFee]); + } logger.info( - { method, methodId, routerBalance: routerBalance.toString(), amountToSend: amountToSend.toString() }, - "Channel is collateralized", - ); - logger.debug( { method, methodId, + routerBalance: routerBalance.toString(), + amountToSend: amountToSend.toString(), + participant: participantToChannel, }, - "Method complete", + "Channel is undercollateralized", ); - return Result.ok({ - [fromChannel.channelAddress]: fromChannelFee, - [toChannel.channelAddress]: Zero, - }); - } - logger.info( - { - method, - methodId, - routerBalance: routerBalance.toString(), - amountToSend: amountToSend.toString(), - participant: participantToChannel, - }, - "Channel is undercollateralized", - ); - // If participant is bob, then you don't need to worry about deploying - // the channel contract - if (participantToChannel === "bob") { + // If participant is bob, then you don't need to worry about deploying + // the channel contract + if (participantToChannel === "bob") { + logger.debug( + { + method, + methodId, + }, + "Method complete", + ); + + return Result.ok([fromChannelFee, GAS_ESTIMATES.depositBob]); + } + + // Determine if channel needs to be deployed to properly calculate the + // collateral fee + const toChannelCode = await ethReader.getCode(toChannel.channelAddress, toChannel.networkContext.chainId); + if (toChannelCode.isError) { + return Result.fail( + new FeeError(FeeError.reasons.ChainError, { + toChainId: toChannel.networkContext.chainId, + getCodeError: jsonifyError(toChannelCode.getError()!), + }), + ); + } + logger.debug( { method, @@ -499,34 +560,10 @@ export const calculateEstimatedGasFee = async ( }, "Method complete", ); - return Result.ok({ - [fromChannel.channelAddress]: fromChannelFee, - [toChannel.channelAddress]: GAS_ESTIMATES.depositBob, - }); - } - // Determine if channel needs to be deployed to properly calculate the - // collateral fee - const toChannelCode = await ethReader.getCode(toChannel.channelAddress, toChannel.networkContext.chainId); - if (toChannelCode.isError) { - return Result.fail( - new FeeError(FeeError.reasons.ChainError, { - toChainId: toChannel.networkContext.chainId, - getCodeError: jsonifyError(toChannelCode.getError()!), - }), - ); - } - logger.debug( - { - method, - methodId, - }, - "Method complete", - ); - - return Result.ok({ - [fromChannel.channelAddress]: fromChannelFee, - [toChannel.channelAddress]: + return Result.ok([ + fromChannelFee, toChannelCode.getValue() === "0x" ? GAS_ESTIMATES.createChannelAndDepositAlice : GAS_ESTIMATES.depositAlice, - }); + ]); + } }; diff --git a/modules/router/src/services/messaging.ts b/modules/router/src/services/messaging.ts index f4d038c3e..101800bc2 100644 --- a/modules/router/src/services/messaging.ts +++ b/modules/router/src/services/messaging.ts @@ -1,4 +1,12 @@ -import { IBasicMessaging, Result, RouterError, MessagingError, NodeResponses, NodeParams } from "@connext/vector-types"; +import { + IBasicMessaging, + Result, + RouterError, + MessagingError, + NodeResponses, + NodeParams, + EngineParams, +} from "@connext/vector-types"; import { NatsBasicMessagingService, MessagingConfig } from "@connext/vector-utils"; import pino, { BaseLogger } from "pino"; export interface IRouterMessagingService extends IBasicMessaging { @@ -25,6 +33,16 @@ export interface IRouterMessagingService extends IBasicMessaging { response: Result, ): Promise; + onReceiveStartAuction( + publicIdentifier: string, + callback: (runAuction: Result, from: string, inbox: string) => void, + ): Promise; + + respondToAuctionMessage( + inbox: string, + response: Result, + ): Promise; + broadcastMetrics(publicIdentifier: string, metrics: string): Promise; } @@ -69,6 +87,21 @@ export class NatsRouterMessagingService extends NatsBasicMessagingService implem ): Promise { await this.registerCallback(`${publicIdentifier}.*.transfer-quote`, callback, "onReceiveTransferQuoteMessage"); } + // Respond to Node Auctions + + async onReceiveStartAuction( + publicIdentifier: string, + callback: (runAuction: Result, from: string, inbox: string) => void, + ): Promise { + await this.registerCallback(`*.*.start-auction`, callback, "onReceiveStartAuction"); + } + + respondToAuctionMessage( + inbox: string, + response: Result, + ): Promise { + return this.respondToMessage(inbox, response, "respondToAuctionMessage"); + } async broadcastMetrics(publicIdentifier: string, metrics: string): Promise { await this.publish(`${publicIdentifier}.${publicIdentifier}.metrics`, { metrics }); diff --git a/modules/router/src/test/forwarding.spec.ts b/modules/router/src/test/forwarding.spec.ts index 96071c56f..6288503b7 100644 --- a/modules/router/src/test/forwarding.spec.ts +++ b/modules/router/src/test/forwarding.spec.ts @@ -388,6 +388,30 @@ describe(testName, () => { await verifySuccessfulResult(result, mocked, 1); }); + it("successfully forwards a transfer and creates a receiver channel", async () => { + const ctx = generateDefaultTestContext(); + ctx.receiverChannel.networkContext.chainId = 1338; + ctx.senderTransfer.meta.path[0].recipientChainId = realConfig.allowedSwaps[0].toChainId; + ctx.senderTransfer.meta.path[0].recipientAssetId = realConfig.allowedSwaps[0].toAssetId; + const mocked = prepEnv({ ...ctx }); + + node.getStateChannelByParticipants.onFirstCall().resolves(Result.ok(undefined)); + node.setup.resolves(Result.ok({ channelAddress: mkAddress() })); + + const result = await forwarding.forwardTransferCreation( + mocked.event, + routerPublicIdentifier, + signerAddress, + node as INodeService, + store, + testLog, + chainReader as IVectorChainReader, + ); + + expect(node.setup.callCount).to.eq(1); + await verifySuccessfulResult(result, mocked, 1); + }); + it.skip("fails but queues transfers if receiver offline and allowable offline", async () => {}); // Uncancellable failures @@ -573,9 +597,14 @@ describe(testName, () => { }); }); - it("fails with cancellation if no state channel available for receiver", async () => { + it("fails with cancellation if no state channel available for receiver and creation fails", async () => { const ctx = prepEnv(); node.getStateChannelByParticipants.onFirstCall().resolves(Result.ok(undefined)); + node.setup.resolves( + Result.fail( + new ServerNodeServiceError(ServerNodeServiceError.reasons.InvalidParams, mkPublicIdentifier(), "", {}), + ), + ); const result = await forwarding.forwardTransferCreation( ctx.event, @@ -588,8 +617,8 @@ describe(testName, () => { ); await verifyErrorResult(result, ctx, ForwardTransferCreationError.reasons.RecipientChannelNotFound, { - participants: [routerPublicIdentifier, ctx.receiverChannel.bobIdentifier], - chainId: ctx.receiverChannel.networkContext.chainId, + recipientChainId: ctx.receiverChannel.networkContext.chainId, + recipientIdentifier: ctx.receiverChannel.bobIdentifier, }); }); diff --git a/modules/router/src/test/services/fees.spec.ts b/modules/router/src/test/services/fees.spec.ts index 7b3d3034f..406496ade 100644 --- a/modules/router/src/test/services/fees.spec.ts +++ b/modules/router/src/test/services/fees.spec.ts @@ -6,6 +6,7 @@ import { FullChannelState, SIMPLE_WITHDRAWAL_GAS_ESTIMATE, IVectorChainReader, + GAS_ESTIMATES, } from "@connext/vector-types"; import { BigNumber } from "@ethersproject/bignumber"; import { expect } from "chai"; @@ -19,20 +20,13 @@ import * as feesService from "../../services/fees"; import * as metrics from "../../metrics"; import * as utils from "../../services/utils"; import { parseEther } from "ethers/lib/utils"; +import { One, Zero } from "@ethersproject/constants"; const config = getConfig(); const testName = "Router fees"; const { log } = vectorUtils.getTestLoggers(testName, config.logLevel ?? ("info" as any)); -const GAS_ESTIMATES = { - createChannelAndDepositAlice: BigNumber.from(200_000), // 0x5a78baf521e5739b2b63626566f6b360a242b52734662db439a2c3256d3e1f97 - createChannel: BigNumber.from(150_000), // 0x45690e81cfc5576d11ecda7938ce91af513a873f8c7e4f26bf2a898ee45ae8ab - depositAlice: BigNumber.from(85_000), // 0x0ed5459c7366d862177408328591c6df5c534fe4e1fbf4a5dd0abbe3d9c761b3 - depositBob: BigNumber.from(50_000), - withdraw: SIMPLE_WITHDRAWAL_GAS_ESTIMATE, // 0x4d4466ed10b5d39c0a80be859dc30bca0120b5e8de10ed7155cc0b26da574439 -}; - describe(testName, () => { let ethReader: Sinon.SinonStubbedInstance; let getRebalanceProfileStub: Sinon.SinonStub; @@ -69,7 +63,7 @@ describe(testName, () => { let normalizedGasFeesStub: Sinon.SinonStub; let fees: { flatFee: string; percentageFee: number; gasSubsidyPercentage: number }; - let gasFees: { [channelAddress: string]: BigNumber }; + let gasFees: [fromFee: BigNumber, toFee: BigNumber]; beforeEach(() => { // default values @@ -94,10 +88,7 @@ describe(testName, () => { flatFee: "300", gasSubsidyPercentage: 0, }; - gasFees = { - [fromChannel.channelAddress]: BigNumber.from(50), - [toChannel.channelAddress]: BigNumber.from(25), - }; + gasFees = [BigNumber.from(50), BigNumber.from(25)]; // default stubs onSwapGivenInStub.resolves(Result.ok({ priceImpact: "0", amountOut: transferAmount })); @@ -108,9 +99,9 @@ describe(testName, () => { // by default, these functions should only return gas fee values // i.e. they do nothing normalizedGasFeesStub = Sinon.stub(utils, "normalizeGasFees"); - normalizedGasFeesStub.onFirstCall().resolves(Result.ok(gasFees[fromChannel.channelAddress])); - normalizedGasFeesStub.onSecondCall().resolves(Result.ok(gasFees[toChannel.channelAddress])); - getSwappedAmountStub.resolves(Result.ok(gasFees[toChannel.channelAddress])); + normalizedGasFeesStub.onFirstCall().resolves(Result.ok(gasFees[0])); + normalizedGasFeesStub.onSecondCall().resolves(Result.ok(gasFees[1])); + getSwappedAmountStub.resolves(Result.ok(gasFees[1])); }); it("should work with only static fees", async () => { @@ -120,12 +111,14 @@ describe(testName, () => { transferAmount, false, fromAssetId, - fromChannel, + fromChannel.networkContext.chainId, toAssetId, - toChannel, + toChannel.networkContext.chainId, ethReader as IVectorChainReader, routerIdentifier, log, + fromChannel, + toChannel, ); expect(result.isError).to.be.false; const { fee, amount } = result.getValue(); @@ -141,12 +134,14 @@ describe(testName, () => { transferAmount, true, fromAssetId, - fromChannel, + fromChannel.networkContext.chainId, toAssetId, - toChannel, + toChannel.networkContext.chainId, ethReader as IVectorChainReader, routerIdentifier, log, + fromChannel, + toChannel, ); expect(result.isError).to.be.false; const { fee, amount } = result.getValue(); @@ -162,12 +157,14 @@ describe(testName, () => { transferAmount, true, fromAssetId, - fromChannel, + fromChannel.networkContext.chainId, toAssetId, - toChannel, + toChannel.networkContext.chainId, ethReader as IVectorChainReader, routerIdentifier, log, + fromChannel, + toChannel, ); expect(result.isError).to.be.false; const { fee, amount } = result.getValue(); @@ -186,12 +183,14 @@ describe(testName, () => { _transferAmount, false, fromAssetId, - fromChannel, + fromChannel.networkContext.chainId, toAssetId, - toChannel, + toChannel.networkContext.chainId, ethReader as IVectorChainReader, routerIdentifier, log, + fromChannel, + toChannel, ); expect(result.isError).to.be.false; const { fee, amount } = result.getValue(); @@ -211,12 +210,14 @@ describe(testName, () => { transferAmount, true, fromAssetId, - fromChannel, + fromChannel.networkContext.chainId, toAssetId, - toChannel, + toChannel.networkContext.chainId, ethReader as IVectorChainReader, routerIdentifier, log, + fromChannel, + toChannel, ); expect(result.isError).to.be.false; const { fee, amount } = result.getValue(); @@ -236,12 +237,14 @@ describe(testName, () => { transferAmount, false, fromAssetId, - fromChannel, + fromChannel.networkContext.chainId, toAssetId, - toChannel, + toChannel.networkContext.chainId, ethReader as IVectorChainReader, routerIdentifier, log, + fromChannel, + toChannel, ); expect(result.isError).to.be.false; const { fee, amount } = result.getValue(); @@ -260,16 +263,18 @@ describe(testName, () => { transferAmount, false, fromAssetId, - fromChannel, + fromChannel.networkContext.chainId, toAssetId, - toChannel, + toChannel.networkContext.chainId, ethReader as IVectorChainReader, routerIdentifier, log, + fromChannel, + toChannel, ); expect(result.isError).to.be.false; const percentage = 100 - fees.gasSubsidyPercentage; - const dynamicFees = gasFees[fromChannel.channelAddress].toNumber() * (percentage / 100); + const dynamicFees = gasFees[0].toNumber() * (percentage / 100); const { fee, amount } = result.getValue(); expect(fee).to.be.eq(BigNumber.from(dynamicFees)); expect(amount).to.be.eq(transferAmount); @@ -285,16 +290,18 @@ describe(testName, () => { transferAmount, false, fromAssetId, - fromChannel, + fromChannel.networkContext.chainId, toAssetId, - toChannel, + toChannel.networkContext.chainId, ethReader as IVectorChainReader, routerIdentifier, log, + fromChannel, + toChannel, ); expect(result.isError).to.be.false; const percentage = 100 - fees.gasSubsidyPercentage; - const dynamicFees = gasFees[toChannel.channelAddress].toNumber() * (percentage / 100); + const dynamicFees = gasFees[1].toNumber() * (percentage / 100); const { fee, amount } = result.getValue(); expect(fee).to.be.eq(BigNumber.from(dynamicFees)); expect(amount).to.be.eq(transferAmount); @@ -309,17 +316,18 @@ describe(testName, () => { transferAmount, false, fromAssetId, - fromChannel, + fromChannel.networkContext.chainId, toAssetId, - toChannel, + toChannel.networkContext.chainId, ethReader as IVectorChainReader, routerIdentifier, log, + fromChannel, + toChannel, ); expect(result.isError).to.be.false; const percentage = 100 - fees.gasSubsidyPercentage; - const dynamicFees = - gasFees[toChannel.channelAddress].add(gasFees[fromChannel.channelAddress]).toNumber() * (percentage / 100); + const dynamicFees = gasFees[1].add(gasFees[0]).toNumber() * (percentage / 100); const { fee, amount } = result.getValue(); expect(fee).to.be.eq(BigNumber.from(dynamicFees)); expect(amount).to.be.eq(transferAmount); @@ -330,19 +338,20 @@ describe(testName, () => { transferAmount, false, fromAssetId, - fromChannel, + fromChannel.networkContext.chainId, toAssetId, - toChannel, + toChannel.networkContext.chainId, ethReader as IVectorChainReader, routerIdentifier, log, + fromChannel, + toChannel, ); expect(result.isError).to.be.false; const staticFees = BigNumber.from(fees.flatFee).toNumber() + (transferAmount.toNumber() * fees.percentageFee) / 100; const percentage = 100 - fees.gasSubsidyPercentage; - const dynamicFees = - gasFees[toChannel.channelAddress].add(gasFees[fromChannel.channelAddress]).toNumber() * (percentage / 100); + const dynamicFees = gasFees[1].add(gasFees[0]).toNumber() * (percentage / 100); const { fee, amount } = result.getValue(); expect(fee).to.be.eq(BigNumber.from(staticFees + dynamicFees)); expect(amount).to.be.eq(transferAmount); @@ -354,17 +363,18 @@ describe(testName, () => { transferAmount, true, fromAssetId, - fromChannel, + fromChannel.networkContext.chainId, toAssetId, - toChannel, + toChannel.networkContext.chainId, ethReader as IVectorChainReader, routerIdentifier, log, + fromChannel, + toChannel, ); expect(result.isError).to.be.false; const percentage = 100 - fees.gasSubsidyPercentage; - const dynamicFees = - gasFees[toChannel.channelAddress].add(gasFees[fromChannel.channelAddress]).toNumber() * (percentage / 100); + const dynamicFees = gasFees[1].add(gasFees[0]).toNumber() * (percentage / 100); const expectedFees = BigNumber.from(fees.flatFee).add(transferAmount.mul(11).div(100)).add(dynamicFees); const { fee, amount } = result.getValue(); @@ -373,18 +383,92 @@ describe(testName, () => { expect(amount).to.be.eq(transferAmount.add(expectedFees)); }); + it("should work if there is no fromChannel", async () => { + const result = await feesService.calculateFeeAmount( + transferAmount, + false, + fromAssetId, + fromChannel.networkContext.chainId, + toAssetId, + toChannel.networkContext.chainId, + ethReader as IVectorChainReader, + routerIdentifier, + log, + undefined, + toChannel, + ); + expect(result.isError).to.be.false; + const staticFees = + BigNumber.from(fees.flatFee).toNumber() + (transferAmount.toNumber() * fees.percentageFee) / 100; + const percentage = 100 - fees.gasSubsidyPercentage; + const dynamicFees = gasFees[1].add(gasFees[0]).toNumber() * (percentage / 100); + const { fee, amount } = result.getValue(); + expect(fee).to.be.eq(BigNumber.from(staticFees + dynamicFees)); + expect(amount).to.be.eq(transferAmount); + }); + + it("should work if there is no toChannel", async () => { + const result = await feesService.calculateFeeAmount( + transferAmount, + false, + fromAssetId, + fromChannel.networkContext.chainId, + toAssetId, + toChannel.networkContext.chainId, + ethReader as IVectorChainReader, + routerIdentifier, + log, + fromChannel, + undefined, + ); + expect(result.isError).to.be.false; + const staticFees = + BigNumber.from(fees.flatFee).toNumber() + (transferAmount.toNumber() * fees.percentageFee) / 100; + const percentage = 100 - fees.gasSubsidyPercentage; + const dynamicFees = gasFees[1].add(gasFees[0]).toNumber() * (percentage / 100); + const { fee, amount } = result.getValue(); + expect(fee).to.be.eq(BigNumber.from(staticFees + dynamicFees)); + expect(amount).to.be.eq(transferAmount); + }); + + it("should work if there is no toChannel or fromChannel", async () => { + const result = await feesService.calculateFeeAmount( + transferAmount, + false, + fromAssetId, + fromChannel.networkContext.chainId, + toAssetId, + toChannel.networkContext.chainId, + ethReader as IVectorChainReader, + routerIdentifier, + log, + undefined, + undefined, + ); + expect(result.isError).to.be.false; + const staticFees = + BigNumber.from(fees.flatFee).toNumber() + (transferAmount.toNumber() * fees.percentageFee) / 100; + const percentage = 100 - fees.gasSubsidyPercentage; + const dynamicFees = gasFees[1].add(gasFees[0]).toNumber() * (percentage / 100); + const { fee, amount } = result.getValue(); + expect(fee).to.be.eq(BigNumber.from(staticFees + dynamicFees)); + expect(amount).to.be.eq(transferAmount); + }); + it("should fail if it cannot get swap fees from config", async () => { getFeesStub.returns(Result.fail(new Error("fail"))); const result = await feesService.calculateFeeAmount( transferAmount, false, fromAssetId, - fromChannel, + fromChannel.networkContext.chainId, toAssetId, - toChannel, + toChannel.networkContext.chainId, ethReader as IVectorChainReader, routerIdentifier, log, + fromChannel, + toChannel, ); expect(result.isError).to.be.true; expect(result.getError()?.message).to.be.eq(FeeError.reasons.ConfigError); @@ -397,12 +481,14 @@ describe(testName, () => { transferAmount, false, fromAssetId, - fromChannel, + fromChannel.networkContext.chainId, toAssetId, - toChannel, + toChannel.networkContext.chainId, ethReader as IVectorChainReader, routerIdentifier, log, + fromChannel, + toChannel, ); expect(result.isError).to.be.true; expect(result.getError()?.message).to.be.eq("fail"); @@ -414,12 +500,14 @@ describe(testName, () => { transferAmount, false, fromAssetId, - fromChannel, + fromChannel.networkContext.chainId, toAssetId, - toChannel, + toChannel.networkContext.chainId, ethReader as IVectorChainReader, routerIdentifier, log, + fromChannel, + toChannel, ); expect(result.isError).to.be.true; expect(result.getError()?.message).to.be.eq(FeeError.reasons.ExchangeRateError); @@ -432,12 +520,14 @@ describe(testName, () => { transferAmount, false, fromAssetId, - fromChannel, + fromChannel.networkContext.chainId, toAssetId, - toChannel, + toChannel.networkContext.chainId, ethReader as IVectorChainReader, routerIdentifier, log, + fromChannel, + toChannel, ); expect(result.isError).to.be.true; expect(result.getError()?.message).to.be.eq(FeeError.reasons.ExchangeRateError); @@ -488,13 +578,15 @@ describe(testName, () => { fromChannel.aliceIdentifier = vectorUtils.getRandomChannelSigner().publicIdentifier; const result = await feesService.calculateEstimatedGasFee( toSend, - toAssetId, fromAssetId, - fromChannel, - toChannel, + fromChannel.networkContext.chainId, + toAssetId, + toChannel.networkContext.chainId, ethReader as IVectorChainReader, routerIdentifier, log, + fromChannel, + toChannel, ); expect(result.isError).to.be.true; expect(result.getError()?.message).to.be.eq(FeeError.reasons.ChannelError); @@ -505,13 +597,15 @@ describe(testName, () => { ethReader.getCode.onFirstCall().resolves(Result.fail(new Error("fail")) as any); const result = await feesService.calculateEstimatedGasFee( toSend, - toAssetId, fromAssetId, - fromChannel, - toChannel, + fromChannel.networkContext.chainId, + toAssetId, + toChannel.networkContext.chainId, ethReader as IVectorChainReader, routerIdentifier, log, + fromChannel, + toChannel, ); expect(result.isError).to.be.true; expect(result.getError()?.message).to.be.eq(FeeError.reasons.ChainError); @@ -522,13 +616,15 @@ describe(testName, () => { getRebalanceProfileStub.returns(Result.fail(new Error("fail"))); const result = await feesService.calculateEstimatedGasFee( toSend, - toAssetId, fromAssetId, - fromChannel, - toChannel, + fromChannel.networkContext.chainId, + toAssetId, + toChannel.networkContext.chainId, ethReader as IVectorChainReader, routerIdentifier, log, + fromChannel, + toChannel, ); expect(result.isError).to.be.true; expect(result.getError()?.message).to.be.eq(FeeError.reasons.ConfigError); @@ -539,13 +635,15 @@ describe(testName, () => { toChannel.aliceIdentifier = vectorUtils.getRandomChannelSigner().publicIdentifier; const result = await feesService.calculateEstimatedGasFee( toSend, - toAssetId, fromAssetId, - fromChannel, - toChannel, + fromChannel.networkContext.chainId, + toAssetId, + toChannel.networkContext.chainId, ethReader as IVectorChainReader, routerIdentifier, log, + fromChannel, + toChannel, ); expect(result.isError).to.be.true; expect(result.getError()?.message).to.be.eq(FeeError.reasons.ChannelError); @@ -556,13 +654,15 @@ describe(testName, () => { getSwappedAmountStub.resolves(Result.fail(new Error("fail")) as any); const result = await feesService.calculateEstimatedGasFee( toSend, - toAssetId, fromAssetId, - fromChannel, - toChannel, + fromChannel.networkContext.chainId, + toAssetId, + toChannel.networkContext.chainId, ethReader as IVectorChainReader, routerIdentifier, log, + fromChannel, + toChannel, ); expect(result.isError).to.be.true; expect(result.getError()?.message).to.be.eq(FeeError.reasons.ConversionError); @@ -573,13 +673,15 @@ describe(testName, () => { ethReader.getCode.onSecondCall().resolves(Result.fail(new Error("fail")) as any); const result = await feesService.calculateEstimatedGasFee( toSend, - toAssetId, fromAssetId, - fromChannel, - toChannel, + fromChannel.networkContext.chainId, + toAssetId, + toChannel.networkContext.chainId, ethReader as IVectorChainReader, routerIdentifier, log, + fromChannel, + toChannel, ); expect(result.isError).to.be.true; expect(result.getError()?.message).to.be.eq(FeeError.reasons.ChainError); @@ -592,34 +694,36 @@ describe(testName, () => { fromChannel.balances[0] = { to: [fromChannel.alice, fromChannel.bob], amount: ["780", "0"] }; const result = await feesService.calculateEstimatedGasFee( toSend, - toAssetId, fromAssetId, - fromChannel, - toChannel, + fromChannel.networkContext.chainId, + toAssetId, + toChannel.networkContext.chainId, ethReader as IVectorChainReader, routerIdentifier, log, + fromChannel, + toChannel, ); expect(result.isError).to.be.false; - expect(result.getValue()[fromChannel.channelAddress]).to.be.eq( - GAS_ESTIMATES.withdraw.add(GAS_ESTIMATES.createChannel), - ); + expect(result.getValue()[0]).to.be.eq(GAS_ESTIMATES.createChannel.add(SIMPLE_WITHDRAWAL_GAS_ESTIMATE)); }); it("should work if from channel will reclaim && channel is deployed", async () => { fromChannel.balances[0] = { to: [fromChannel.alice, fromChannel.bob], amount: ["780", "0"] }; const result = await feesService.calculateEstimatedGasFee( toSend, - toAssetId, fromAssetId, - fromChannel, - toChannel, + fromChannel.networkContext.chainId, + toAssetId, + toChannel.networkContext.chainId, ethReader as IVectorChainReader, routerIdentifier, log, + fromChannel, + toChannel, ); expect(result.isError).to.be.false; - expect(result.getValue()[fromChannel.channelAddress]).to.be.eq(GAS_ESTIMATES.withdraw); + expect(result.getValue()[0]).to.be.eq(SIMPLE_WITHDRAWAL_GAS_ESTIMATE); }); it("should work if from channel will collateralize && router is bob", async () => { @@ -634,16 +738,18 @@ describe(testName, () => { // from channel calls const result = await feesService.calculateEstimatedGasFee( BigNumber.from(3), - toAssetId, fromAssetId, - fromChannel, - toChannel, + fromChannel.networkContext.chainId, + toAssetId, + toChannel.networkContext.chainId, ethReader as IVectorChainReader, routerIdentifier, log, + fromChannel, + toChannel, ); expect(result.isError).to.be.false; - expect(result.getValue()[fromChannel.channelAddress]).to.be.eq(GAS_ESTIMATES.depositBob); + expect(result.getValue()[0]).to.be.eq(GAS_ESTIMATES.depositBob); }); it("should work if from channel will collateralize && router is alice && channel is not deployed", async () => { @@ -654,53 +760,132 @@ describe(testName, () => { }; const result = await feesService.calculateEstimatedGasFee( BigNumber.from(3), - toAssetId, fromAssetId, + fromChannel.networkContext.chainId, + toAssetId, + toChannel.networkContext.chainId, + ethReader as IVectorChainReader, + routerIdentifier, + log, fromChannel, toChannel, + ); + expect(result.isError).to.be.false; + expect(result.getValue()[0]).to.be.eq(GAS_ESTIMATES.createChannelAndDepositAlice); + }); + + it("should work if from channel will collateralize && router is alice && channel is deployed", async () => { + fromChannel.balances[0] = { + to: [fromChannel.alice, fromChannel.bob], + amount: ["0", "0"], + }; + const result = await feesService.calculateEstimatedGasFee( + BigNumber.from(3), + fromAssetId, + fromChannel.networkContext.chainId, + toAssetId, + toChannel.networkContext.chainId, ethReader as IVectorChainReader, routerIdentifier, log, + fromChannel, + toChannel, ); expect(result.isError).to.be.false; - expect(result.getValue()[fromChannel.channelAddress]).to.be.eq(GAS_ESTIMATES.createChannelAndDepositAlice); + expect(result.getValue()[0]).to.be.eq(GAS_ESTIMATES.depositAlice); }); - it("should work if from channel will collateralize && router is alice && channel is deployed", async () => { + it("should work if no fromChannel and will collateralize", async () => { fromChannel.balances[0] = { to: [fromChannel.alice, fromChannel.bob], amount: ["0", "0"], }; const result = await feesService.calculateEstimatedGasFee( BigNumber.from(3), + fromAssetId, + fromChannel.networkContext.chainId, toAssetId, + toChannel.networkContext.chainId, + ethReader as IVectorChainReader, + routerIdentifier, + log, + undefined, + toChannel, + ); + expect(result.isError).to.be.false; + expect(result.getValue()[0]).to.be.eq(GAS_ESTIMATES.createChannelAndDepositAlice); + }); + + it("should work if no fromChannel and will reclaim", async () => { + const result = await feesService.calculateEstimatedGasFee( + BigNumber.from(101), fromAssetId, - fromChannel, + fromChannel.networkContext.chainId, + toAssetId, + toChannel.networkContext.chainId, + ethReader as IVectorChainReader, + routerIdentifier, + log, + undefined, toChannel, + ); + expect(result.isError).to.be.false; + expect(result.getValue()[0]).to.be.eq(GAS_ESTIMATES.createChannel.add(SIMPLE_WITHDRAWAL_GAS_ESTIMATE)); + }); + + it("should work if no fromChannel and will collateralize", async () => { + const result = await feesService.calculateEstimatedGasFee( + BigNumber.from(3), + fromAssetId, + fromChannel.networkContext.chainId, + toAssetId, + toChannel.networkContext.chainId, ethReader as IVectorChainReader, routerIdentifier, log, + undefined, + toChannel, ); expect(result.isError).to.be.false; - expect(result.getValue()[fromChannel.channelAddress]).to.be.eq(GAS_ESTIMATES.depositAlice); + expect(result.getValue()[0]).to.be.eq(GAS_ESTIMATES.createChannelAndDepositAlice); }); }); describe("should work for toChannel actions", () => { it("should work if to channel will do nothing", async () => { - toChannel.balances[0] = { to: [toChannel.alice, toChannel.bob], amount: ["780", "0"] }; + toChannel.balances[0] = { to: [toChannel.alice, toChannel.bob], amount: ["301", "0"] }; const result = await feesService.calculateEstimatedGasFee( - toSend, - toAssetId, + One, fromAssetId, + fromChannel.networkContext.chainId, + toAssetId, + toChannel.networkContext.chainId, + ethReader as IVectorChainReader, + routerIdentifier, + log, fromChannel, toChannel, + ); + expect(result.isError).to.be.false; + expect(result.getValue()[1]).to.be.eq(0); + }); + + it("should work if to channel will reclaim", async () => { + toChannel.balances[0] = { to: [toChannel.alice, toChannel.bob], amount: ["780", "0"] }; + const result = await feesService.calculateEstimatedGasFee( + One, + fromAssetId, + fromChannel.networkContext.chainId, + toAssetId, + toChannel.networkContext.chainId, ethReader as IVectorChainReader, routerIdentifier, log, + fromChannel, + toChannel, ); expect(result.isError).to.be.false; - expect(result.getValue()[toChannel.channelAddress]).to.be.eq(0); + expect(result.getValue()[1]).to.be.eq(GAS_ESTIMATES.withdraw); }); it("should work if to channel will collatearlize && router is bob", async () => { @@ -712,16 +897,18 @@ describe(testName, () => { const result = await feesService.calculateEstimatedGasFee( toSend, - toAssetId, fromAssetId, - fromChannel, - toChannel, + fromChannel.networkContext.chainId, + toAssetId, + toChannel.networkContext.chainId, ethReader as IVectorChainReader, routerIdentifier, log, + fromChannel, + toChannel, ); expect(result.isError).to.be.false; - expect(result.getValue()[toChannel.channelAddress]).to.be.eq(GAS_ESTIMATES.depositBob); + expect(result.getValue()[1]).to.be.eq(GAS_ESTIMATES.depositBob); }); it("should work if to channel will collateralize && router is alice && channel is not deployed", async () => { @@ -730,16 +917,18 @@ describe(testName, () => { const result = await feesService.calculateEstimatedGasFee( toSend, - toAssetId, fromAssetId, - fromChannel, - toChannel, + fromChannel.networkContext.chainId, + toAssetId, + toChannel.networkContext.chainId, ethReader as IVectorChainReader, routerIdentifier, log, + fromChannel, + toChannel, ); expect(result.isError).to.be.false; - expect(result.getValue()[toChannel.channelAddress]).to.be.eq(GAS_ESTIMATES.createChannelAndDepositAlice); + expect(result.getValue()[1]).to.be.eq(GAS_ESTIMATES.createChannelAndDepositAlice); }); it("should work if to channel will collateralize && router is alice && channel is deployed", async () => { @@ -747,17 +936,71 @@ describe(testName, () => { const result = await feesService.calculateEstimatedGasFee( toSend, - toAssetId, fromAssetId, + fromChannel.networkContext.chainId, + toAssetId, + toChannel.networkContext.chainId, + ethReader as IVectorChainReader, + routerIdentifier, + log, fromChannel, toChannel, + ); + expect(result.isError).to.be.false; + expect(result.getValue()[1]).to.be.eq(GAS_ESTIMATES.depositAlice); + }); + + it("should work if no toChannel and will collateralize", async () => { + const result = await feesService.calculateEstimatedGasFee( + toSend, + fromAssetId, + fromChannel.networkContext.chainId, + toAssetId, + toChannel.networkContext.chainId, ethReader as IVectorChainReader, routerIdentifier, log, + fromChannel, + undefined, ); expect(result.isError).to.be.false; - expect(result.getValue()[toChannel.channelAddress]).to.be.eq(GAS_ESTIMATES.depositAlice); + expect(result.getValue()[1]).to.be.eq(GAS_ESTIMATES.createChannelAndDepositAlice); }); + + it("should work if no toChannel and will reclaim", async () => { + const result = await feesService.calculateEstimatedGasFee( + toSend, + fromAssetId, + fromChannel.networkContext.chainId, + toAssetId, + toChannel.networkContext.chainId, + ethReader as IVectorChainReader, + routerIdentifier, + log, + fromChannel, + undefined, + ); + expect(result.isError).to.be.false; + expect(result.getValue()[1]).to.be.eq(GAS_ESTIMATES.createChannelAndDepositAlice); + }); + }); + + it("should work if no toChannel or fromChannel and sendAmount lte ", async () => { + const result = await feesService.calculateEstimatedGasFee( + toSend, + fromAssetId, + fromChannel.networkContext.chainId, + toAssetId, + toChannel.networkContext.chainId, + ethReader as IVectorChainReader, + routerIdentifier, + log, + undefined, + undefined, + ); + expect(result.isError).to.be.false; + expect(result.getValue()[0]).to.be.eq(Zero); + expect(result.getValue()[1]).to.be.eq(GAS_ESTIMATES.createChannelAndDepositAlice); }); }); }); diff --git a/modules/router/src/test/services/messaging.spec.ts b/modules/router/src/test/services/messaging.spec.ts index 75869cdda..9a61825b7 100644 --- a/modules/router/src/test/services/messaging.spec.ts +++ b/modules/router/src/test/services/messaging.spec.ts @@ -1,6 +1,15 @@ -import { getRandomChannelSigner, getTestLoggers, NatsMessagingService, expect, delay } from "@connext/vector-utils"; +import { + getRandomChannelSigner, + getTestLoggers, + NatsMessagingService, + expect, + delay, + mkPublicIdentifier, + mkAddress, + mkSig, +} from "@connext/vector-utils"; import pino from "pino"; -import { IChannelSigner, Result } from "@connext/vector-types"; +import { IChannelSigner, NodeResponses, Result } from "@connext/vector-types"; import { NatsRouterMessagingService } from "../../services/messaging"; import { getConfig } from "../../config"; @@ -13,6 +22,7 @@ describe("messaging.ts", () => { let messaging: NatsMessagingService; let router: IChannelSigner; let signer: IChannelSigner; + const inbox = "mock_inbox"; beforeEach(async () => { signer = getRandomChannelSigner(); @@ -57,4 +67,56 @@ describe("messaging.ts", () => { expect(res.isError).to.be.false; expect(res.getValue()).to.be.deep.eq(configResponse); }); + + // TODO: replace hardcoded swapRate + it("should properly respond with auction response when requested", async () => { + const auctionResponse: NodeResponses.RunAuction = { + routerPublicIdentifier: router.publicIdentifier, + swapRate: "1", + totalFee: config.baseFlatFee as string, + quote: { + amount: "2", + recipient: mkPublicIdentifier(), + assetId: mkAddress(), + chainId: 123, + expiry: "1234", + fee: "1", + recipientAssetId: mkAddress(), + recipientChainId: 321, + routerIdentifier: mkPublicIdentifier(), + signature: mkSig(), + }, + }; + + await routerMessaging.onReceiveStartAuction( + router.publicIdentifier, + async (result: Result, from: string, inbox: string) => { + expect(result.isError).to.not.be.ok; + expect(result.getValue()).to.not.be.ok; + expect(inbox).to.be.a("string"); + expect(from).to.be.eq(signer.publicIdentifier); + await routerMessaging.respondToAuctionMessage(inbox, Result.ok(auctionResponse)); + }, + ); + + await messaging.publishStartAuction( + signer.publicIdentifier, + signer.publicIdentifier, + Result.ok({ + amount: "1", + assetId: "0x000", + chainId: 1, + recipient: signer.publicIdentifier, + recipientChainId: 1, + recipientAssetId: "0x000", + }), + inbox, + ); + + await delay(1_000); + await messaging.onReceiveAuctionMessage(signer.publicIdentifier, inbox, (runAuction) => { + expect(runAuction.isError).to.be.false; + expect(runAuction.getValue()).to.be.deep.eq(auctionResponse); + }); + }); }); diff --git a/modules/server-node/examples/0-config.http b/modules/server-node/examples/0-config.http index e4bb15939..569158053 100644 --- a/modules/server-node/examples/0-config.http +++ b/modules/server-node/examples/0-config.http @@ -70,4 +70,4 @@ Content-Type: application/json { "index": 0 -} +} \ No newline at end of file diff --git a/modules/server-node/examples/1-setup.http b/modules/server-node/examples/1-setup.http index 57ecaf058..d9fe0f097 100644 --- a/modules/server-node/examples/1-setup.http +++ b/modules/server-node/examples/1-setup.http @@ -28,4 +28,4 @@ Content-Type: application/json "publicIdentifier": "{{nodePublicIdentifier}}", "chainId": "{{chainId}}", "timeout": "100000" -} \ No newline at end of file +} diff --git a/modules/server-node/examples/auction.http b/modules/server-node/examples/auction.http new file mode 100644 index 000000000..af681fb24 --- /dev/null +++ b/modules/server-node/examples/auction.http @@ -0,0 +1,40 @@ +@aliceUrl = http://localhost:8003 +@bobUrl = http://localhost:8004 +@carolUrl = http://localhost:8005 +@daveUrl = http://localhost:8006 +@rogerUrl = http://localhost:8007 +@roger1Url = http://localhost:8017 +@roger2Url = http://localhost:8027 +@aliceBobChannel = 0x47809CD3218c69aB21BeEe8ad6a7b7Ec5E026859 +@carolRogerChannel = 0x66920C67620b492C3FF7f904af6DC3a8B58D7C9C +@daveRogerChannel = 0x7E513218D56ef4465208d587e9eff56e9035cd02 +@adminToken = cxt1234 +@alicePublicIdentifier = vector8WxfqTu8EC2FLM6g4y6TgbSrx4EPP9jeDFQk3VBsBM7Jv8NakR +@bobPublicIdentifier = vector5ArRsL26avPNyfvJd2qMAppsEVeJv11n31ex542T9gCd5B1cP3 +@carolPublicIdentifier = vector8ZaxNSdUM83kLXJSsmj5jrcq17CpZUwBirmboaNPtQMEXjVNrL +@davePublicIdentifier = vector7mAydt3S3dDPWJMYSHZPdRo16Pru145qTNQYFoS8TrpXWW8HAj +@rogerPublicIdentifier = vector8Uz1BdpA9hV5uTm6QUv5jj1PsUyCH8m8ciA94voCzsxVmrBRor +@roger1PublicIdentifier = vector5ANRAPfyUHv5aFB4iUxZ8ABfCE8ykDn3d1qkoUVfW7qFmmegur +@roger2PublicIdentifier = vector5jDmgMUxaSzqq6nqWXvxh1eN3kyofeJrBrJsMKUwS3zsQHPSzj +@chainId = 1337 + +@nodeUrl = {{carolUrl}} +@nodePublicIdentifier = {{carolPublicIdentifier}} +@counterpartyPublicIdentifier = {{rogerPublicIdentifier}} +@channel = {{carolRogerChannel}} + +############## +### Start Auction + +POST {{carolUrl}}/run-auction +Content-Type: application/json + +{ + "amount": "1000000", + "publicIdentifier": "{{carolPublicIdentifier}}", + "assetId": "0x0000000000000000000000000000000000000000", + "chainId": 1337, + "recipient": "{{davePublicIdentifier}}", + "recipientAssetId": "0x0000000000000000000000000000000000000000", + "recipientChainId": 1337 +} \ No newline at end of file diff --git a/modules/server-node/ops/webpack.config.js b/modules/server-node/ops/webpack.config.js index d710685b6..a257e3645 100644 --- a/modules/server-node/ops/webpack.config.js +++ b/modules/server-node/ops/webpack.config.js @@ -69,6 +69,10 @@ module.exports = { from: path.join(__dirname, "../node_modules/@connext/vector-contracts/dist/pure-evm_bg.wasm"), to: path.join(__dirname, "../dist/pure-evm_bg.wasm"), }, + { + from: path.join(__dirname, "../../../node_modules/@connext/vector-merkle-tree/dist/node/index_bg.wasm"), + to: path.join(__dirname, "../dist/index_bg.wasm"), + }, { from: path.join(__dirname, "../prisma-postgres"), to: path.join(__dirname, "../dist/prisma-postgres"), diff --git a/modules/server-node/package.json b/modules/server-node/package.json index f9087c754..809e47313 100644 --- a/modules/server-node/package.json +++ b/modules/server-node/package.json @@ -14,10 +14,10 @@ "migration:generate:sqlite": "prisma migrate dev --create-only --preview-feature --schema prisma-sqlite/schema.prisma" }, "dependencies": { - "@connext/vector-contracts": "0.2.5-beta.18", - "@connext/vector-engine": "0.2.5-beta.18", - "@connext/vector-types": "0.2.5-beta.18", - "@connext/vector-utils": "0.2.5-beta.18", + "@connext/vector-contracts": "0.3.0-beta.2", + "@connext/vector-engine": "0.3.0-beta.2", + "@connext/vector-types": "0.3.0-beta.2", + "@connext/vector-utils": "0.3.0-beta.2", "@ethersproject/wallet": "5.2.0", "@prisma/client": "2.22.0", "@sinclair/typebox": "0.12.7", diff --git a/modules/server-node/prisma-postgres/migrations/20210602212808_add_update_id/migration.sql b/modules/server-node/prisma-postgres/migrations/20210602212808_add_update_id/migration.sql new file mode 100644 index 000000000..8db587da4 --- /dev/null +++ b/modules/server-node/prisma-postgres/migrations/20210602212808_add_update_id/migration.sql @@ -0,0 +1,17 @@ +/* + Warnings: + + - You are about to drop the column `merkleProofData` on the `update` table. All the data in the column will be lost. + - A unique constraint covering the columns `[id]` on the table `update` will be added. If there are existing duplicate values, this will fail. + +*/ +-- AlterTable +ALTER TABLE "onchain_transaction" ALTER COLUMN "id" DROP DEFAULT; + +-- AlterTable +ALTER TABLE "update" DROP COLUMN "merkleProofData", +ADD COLUMN "id" TEXT, +ADD COLUMN "idSignature" TEXT; + +-- CreateIndex +CREATE UNIQUE INDEX "update.id_unique" ON "update"("id"); diff --git a/modules/server-node/prisma-postgres/schema.prisma b/modules/server-node/prisma-postgres/schema.prisma index b1f2e8568..14be53c8b 100644 --- a/modules/server-node/prisma-postgres/schema.prisma +++ b/modules/server-node/prisma-postgres/schema.prisma @@ -79,6 +79,9 @@ model Channel { model Update { // COMMON PARAMS + id String? + idSignature String? + // id params optional for restoring transfers (needs create update) channelAddress String? channel Channel? @relation(fields: [channelAddress], references: [channelAddress]) channelAddressId String // required for ID so that relation can be removed @@ -114,7 +117,6 @@ model Update { transferTimeout String? transferInitialState String? // JSON string transferEncodings String? - merkleProofData String? // proofs.join(",") meta String? responder String? @@ -128,6 +130,7 @@ model Update { resolvedTransfer Transfer? @relation("ResolvedTransfer") @@id([channelAddressId, nonce]) + @@unique(id) @@map(name: "update") } diff --git a/modules/server-node/prisma-sqlite/migrations/20210602212112_add_update_id/migration.sql b/modules/server-node/prisma-sqlite/migrations/20210602212112_add_update_id/migration.sql new file mode 100644 index 000000000..3ed5286ce --- /dev/null +++ b/modules/server-node/prisma-sqlite/migrations/20210602212112_add_update_id/migration.sql @@ -0,0 +1,51 @@ +/* + Warnings: + + - You are about to drop the column `merkleProofData` on the `update` table. All the data in the column will be lost. + +*/ +-- RedefineTables +PRAGMA foreign_keys=OFF; +CREATE TABLE "new_update" ( + "id" TEXT, + "idSignature" TEXT, + "channelAddress" TEXT, + "channelAddressId" TEXT NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "fromIdentifier" TEXT NOT NULL, + "toIdentifier" TEXT NOT NULL, + "type" TEXT NOT NULL, + "nonce" INTEGER NOT NULL, + "amountA" TEXT NOT NULL, + "amountB" TEXT NOT NULL, + "toA" TEXT NOT NULL, + "toB" TEXT NOT NULL, + "assetId" TEXT NOT NULL, + "signatureA" TEXT, + "signatureB" TEXT, + "totalDepositsAlice" TEXT, + "totalDepositsBob" TEXT, + "transferAmountA" TEXT, + "transferAmountB" TEXT, + "transferToA" TEXT, + "transferToB" TEXT, + "transferId" TEXT, + "transferDefinition" TEXT, + "transferTimeout" TEXT, + "transferInitialState" TEXT, + "transferEncodings" TEXT, + "meta" TEXT, + "responder" TEXT, + "transferResolver" TEXT, + "merkleRoot" TEXT, + + PRIMARY KEY ("channelAddressId", "nonce"), + FOREIGN KEY ("channelAddress") REFERENCES "channel" ("channelAddress") ON DELETE SET NULL ON UPDATE CASCADE +); +INSERT INTO "new_update" ("channelAddress", "channelAddressId", "createdAt", "fromIdentifier", "toIdentifier", "type", "nonce", "amountA", "amountB", "toA", "toB", "assetId", "signatureA", "signatureB", "totalDepositsAlice", "totalDepositsBob", "transferAmountA", "transferAmountB", "transferToA", "transferToB", "transferId", "transferDefinition", "transferTimeout", "transferInitialState", "transferEncodings", "meta", "responder", "transferResolver", "merkleRoot") SELECT "channelAddress", "channelAddressId", "createdAt", "fromIdentifier", "toIdentifier", "type", "nonce", "amountA", "amountB", "toA", "toB", "assetId", "signatureA", "signatureB", "totalDepositsAlice", "totalDepositsBob", "transferAmountA", "transferAmountB", "transferToA", "transferToB", "transferId", "transferDefinition", "transferTimeout", "transferInitialState", "transferEncodings", "meta", "responder", "transferResolver", "merkleRoot" FROM "update"; +DROP TABLE "update"; +ALTER TABLE "new_update" RENAME TO "update"; +CREATE UNIQUE INDEX "update.id_unique" ON "update"("id"); +CREATE UNIQUE INDEX "update_channelAddress_unique" ON "update"("channelAddress"); +PRAGMA foreign_key_check; +PRAGMA foreign_keys=ON; diff --git a/modules/server-node/prisma-sqlite/schema.prisma b/modules/server-node/prisma-sqlite/schema.prisma index dfdeaf808..2ed364a4c 100644 --- a/modules/server-node/prisma-sqlite/schema.prisma +++ b/modules/server-node/prisma-sqlite/schema.prisma @@ -79,6 +79,9 @@ model Channel { model Update { // COMMON PARAMS + id String? + idSignature String? + // id params optional for restoring transfers (needs create update) channelAddress String? channel Channel? @relation(fields: [channelAddress], references: [channelAddress]) channelAddressId String // required for ID so that relation can be removed @@ -114,7 +117,6 @@ model Update { transferTimeout String? transferInitialState String? // JSON string transferEncodings String? - merkleProofData String? // proofs.join(",") meta String? responder String? @@ -128,6 +130,7 @@ model Update { resolvedTransfer Transfer? @relation("ResolvedTransfer") @@id([channelAddressId, nonce]) + @@unique(id) @@map(name: "update") } diff --git a/modules/server-node/src/helpers/nodes.ts b/modules/server-node/src/helpers/nodes.ts index 0953f5430..4b3edcb66 100644 --- a/modules/server-node/src/helpers/nodes.ts +++ b/modules/server-node/src/helpers/nodes.ts @@ -1,20 +1,15 @@ import { VectorChainService } from "@connext/vector-contracts"; import { VectorEngine } from "@connext/vector-engine"; -import { EngineEvents, ILockService, IVectorChainService, IVectorEngine, IServerNodeStore } from "@connext/vector-types"; +import { EngineEvents, IVectorChainService, IVectorEngine, IServerNodeStore } from "@connext/vector-types"; import { ChannelSigner, NatsMessagingService, logAxiosError } from "@connext/vector-utils"; import Axios from "axios"; import { Wallet } from "@ethersproject/wallet"; import { logger, _providers } from "../index"; import { config } from "../config"; -import { LockService } from "../services/lock"; const ETH_STANDARD_PATH = "m/44'/60'/0'/0"; -export function getLockService(publicIdentifier: string): ILockService | undefined { - return nodes[publicIdentifier]?.lockService; -} - export function getPath(index = 0): string { return `${ETH_STANDARD_PATH}/${(String(index).match(/.{1,9}/gi) || [index]).join("/")}`; } @@ -27,7 +22,6 @@ export let nodes: { [publicIdentifier: string]: { node: IVectorEngine; chainService: IVectorChainService; - lockService: ILockService; index: number; }; } = {}; @@ -66,16 +60,8 @@ export const createNode = async ( await messaging.connect(); logger.info({ method, messagingUrl: config.messagingUrl }, "Connected NatsMessagingService"); - const lockService = await LockService.connect( - signer.publicIdentifier, - messaging, - logger.child({ module: "LockService" }), - ); - logger.info({ method }, "Connected LockService"); - const vectorEngine = await VectorEngine.connect( messaging, - lockService, store, signer, vectorTx, @@ -102,7 +88,7 @@ export const createNode = async ( logger.info({ event, method, publicIdentifier: signer.publicIdentifier, index }, "Set up subscription for event"); } - nodes[signer.publicIdentifier] = { node: vectorEngine, chainService: vectorTx, index, lockService }; + nodes[signer.publicIdentifier] = { node: vectorEngine, chainService: vectorTx, index }; store.setNodeIndex(index, signer.publicIdentifier); return vectorEngine; }; diff --git a/modules/server-node/src/index.ts b/modules/server-node/src/index.ts index 7460d032c..4d9ff5ce7 100644 --- a/modules/server-node/src/index.ts +++ b/modules/server-node/src/index.ts @@ -17,10 +17,8 @@ import { GetTransfersFilterOpts, GetTransfersFilterOptsSchema, VectorErrorJson, - StoredTransaction, } from "@connext/vector-types"; import { constructRpcRequest, getPublicIdentifierFromPublicKey, hydrateProviders } from "@connext/vector-utils"; -import { WithdrawCommitment } from "@connext/vector-contracts"; import { Static, Type } from "@sinclair/typebox"; import { Wallet } from "@ethersproject/wallet"; @@ -858,6 +856,34 @@ server.post<{ Body: NodeParams.SendIsAlive }>( }, ); +server.post<{ Body: NodeParams.RunAuction }>( + "/run-auction", + { schema: { body: NodeParams.RunAuctionSchema, response: NodeResponses.RunAuctionSchema } }, + async (request, reply) => { + const engine = getNode(request.body.publicIdentifier); + if (!engine) { + return reply + .status(400) + .send( + jsonifyError( + new ServerNodeError(ServerNodeError.reasons.NodeNotFound, request.body.publicIdentifier, request.body), + ), + ); + } + const rpc = constructRpcRequest(ChannelRpcMethods.chan_runAuction, request.body); + try { + const { routerPublicIdentifier, swapRate, totalFee, quote } = await engine.request< + typeof ChannelRpcMethods.chan_runAuction + >(rpc); + + return reply.status(200).send({ routerPublicIdentifier, swapRate, totalFee, quote } as NodeResponses.RunAuction); + } catch (e) { + logger.error({ error: jsonifyError(e) }); + return reply.status(500).send(jsonifyError(e)); + } + }, +); + server.post<{ Body: NodeParams.RegisterListener }>( "/event/subscribe", { diff --git a/modules/server-node/src/services/lock.ts b/modules/server-node/src/services/lock.ts deleted file mode 100644 index 3c94aa191..000000000 --- a/modules/server-node/src/services/lock.ts +++ /dev/null @@ -1,160 +0,0 @@ -import { - ILockService, - IMessagingService, - LockInformation, - NodeError, - Result, - jsonifyError, -} from "@connext/vector-types"; -import { MemoryLockService } from "@connext/vector-utils"; -import { BaseLogger } from "pino"; - -import { ServerNodeLockError } from "../helpers/errors"; - -export class LockService implements ILockService { - private constructor( - private readonly memoryLockService: MemoryLockService, - private readonly publicIdentifier: string, - private readonly messagingService: IMessagingService, - private readonly log: BaseLogger, - ) {} - - static async connect( - publicIdentifier: string, - messagingService: IMessagingService, - log: BaseLogger, - ): Promise { - const memoryLockService = new MemoryLockService(); - const lock = new LockService(memoryLockService, publicIdentifier, messagingService, log); - await lock.setupPeerListeners(); - return lock; - } - - private async setupPeerListeners(): Promise { - // Alice always hosts the lock service, so only alice will use - // this callback - return this.messagingService.onReceiveLockMessage( - this.publicIdentifier, - async (lockRequest: Result, from: string, inbox: string) => { - if (lockRequest.isError) { - // Handle a lock failure here - this.log.error( - { - method: "onReceiveLockMessage", - error: lockRequest.getError()?.message, - context: lockRequest.getError()?.context, - }, - "Error in lockRequest", - ); - return; - } - const { type, lockName, lockValue } = lockRequest.getValue(); - if (type === "acquire") { - let acqValue; - let method = "acquireLock"; - try { - acqValue = await this.acquireLock(lockName, true); - method = "respondToLockMessage"; - await this.messagingService.respondToLockMessage(inbox, Result.ok({ lockName, lockValue: acqValue, type })); - } catch (e) { - this.log.error( - { - method: "onReceiveLockMessage", - error: e.message, - }, - "Error acquiring lock", - ); - await this.messagingService.respondToLockMessage( - inbox, - Result.fail( - new ServerNodeLockError(ServerNodeLockError.reasons.AcquireLockFailed, lockName, lockValue, { - acqValue, - failingMethod: method, - lockError: e.message, - }), - ), - ); - } - } else if (type === "release") { - let method = "releaseLock"; - try { - await this.releaseLock(lockName, lockValue!, true); - method = "respondToLockMessage"; - await this.messagingService.respondToLockMessage(inbox, Result.ok({ lockName, type })); - } catch (e) { - this.log.error( - { - method: "onReceiveLockMessage", - error: e.message, - }, - "Error releasing lock", - ); - await this.messagingService.respondToLockMessage( - inbox, - Result.fail( - new ServerNodeLockError(ServerNodeLockError.reasons.FailedToReleaseLock, lockName, lockValue, { - failingMethod: method, - releaseError: e.message, - ...(e.context ?? {}), - }), - ), - ); - } - } - }, - ); - } - - public async acquireLock(lockName: string, isAlice = true, counterpartyPublicIdentifier?: string): Promise { - if (isAlice) { - return this.memoryLockService.acquireLock(lockName); - } else { - const res = await this.messagingService.sendLockMessage( - Result.ok({ type: "acquire", lockName }), - counterpartyPublicIdentifier!, - this.publicIdentifier, - ); - if (res.isError) { - throw new ServerNodeLockError(ServerNodeLockError.reasons.AcquireMessageFailed, lockName, undefined, { - counterpartyPublicIdentifier, - isAlice, - messagingError: jsonifyError(res.getError()!), - }); - } - const { lockValue } = res.getValue(); - if (!lockValue) { - throw new ServerNodeLockError(ServerNodeLockError.reasons.SentMessageAcquisitionFailed, lockName, lockValue, { - counterpartyPublicIdentifier, - isAlice, - }); - } - this.log.debug({ method: "acquireLock", lockName, lockValue }, "Acquired lock"); - return lockValue; - } - } - - public async releaseLock( - lockName: string, - lockValue: string, - isAlice = true, - counterpartyPublicIdentifier?: string, - ): Promise { - if (isAlice) { - return this.memoryLockService.releaseLock(lockName, lockValue); - } else { - const result = await this.messagingService.sendLockMessage( - Result.ok({ type: "release", lockName, lockValue }), - counterpartyPublicIdentifier!, - this.publicIdentifier, - ); - if (result.isError) { - throw new ServerNodeLockError(ServerNodeLockError.reasons.ReleaseMessageFailed, lockName, lockValue, { - messagingError: jsonifyError(result.getError()!), - counterpartyPublicIdentifier, - isAlice, - }); - } - this.log.debug({ method: "releaseLock", lockName, lockValue }, "Released lock"); - } - } -} diff --git a/modules/server-node/src/services/messaging.spec.ts b/modules/server-node/src/services/messaging.spec.ts index deb6571ac..d085c0c20 100644 --- a/modules/server-node/src/services/messaging.spec.ts +++ b/modules/server-node/src/services/messaging.spec.ts @@ -1,4 +1,12 @@ -import { IChannelSigner, Result, jsonifyError, MessagingError, UpdateType, VectorError } from "@connext/vector-types"; +import { + IChannelSigner, + Result, + jsonifyError, + MessagingError, + UpdateType, + VectorError, + PROTOCOL_VERSION, +} from "@connext/vector-types"; import { createTestChannelUpdate, delay, @@ -12,7 +20,6 @@ import { import pino from "pino"; import { config } from "../config"; -import { ServerNodeLockError } from "../helpers/errors"; describe("messaging", () => { const { log: logger } = getTestLoggers("messaging", (config.logLevel ?? "fatal") as pino.Level); @@ -57,13 +64,13 @@ describe("messaging", () => { expect(result.isError).to.not.be.ok; expect(result.getValue()).to.containSubset({ update }); expect(inbox).to.be.a("string"); - await messagingB.respondToProtocolMessage(inbox, update); + await messagingB.respondToProtocolMessage(inbox, PROTOCOL_VERSION, update); }, ); await delay(1_000); - const res = await messagingA.sendProtocolMessage(update); + const res = await messagingA.sendProtocolMessage(PROTOCOL_VERSION, update); expect(res.isError).to.not.be.ok; expect(res.getValue()).to.containSubset({ update }); }); @@ -88,7 +95,7 @@ describe("messaging", () => { await delay(1_000); - const res = await messagingA.sendProtocolMessage(update); + const res = await messagingA.sendProtocolMessage(PROTOCOL_VERSION, update); expect(res.isError).to.be.true; const errReceived = res.getError()!; const expected = VectorError.fromJson(jsonifyError(err)); @@ -111,28 +118,6 @@ describe("messaging", () => { response: Result.fail(new Error("responder failure")), type: "Setup", }, - { - name: "lock should work from A --> B", - message: Result.ok({ - type: "acquire", - lockName: mkAddress("0xccc"), - }), - response: Result.ok({ - type: "acquire", - lockName: mkAddress("0xccc"), - }), - type: "Lock", - }, - { - name: "lock send failure messages properly from A --> B", - message: Result.fail( - new ServerNodeLockError("sender failure" as any, mkAddress("0xccc"), "", { type: "release" }), - ), - response: Result.fail( - new ServerNodeLockError("responder failure" as any, mkAddress("0xccc"), "", { type: "acquire" }), - ), - type: "Lock", - }, { name: "requestCollateral should work from A --> B", message: Result.ok({ diff --git a/modules/server-node/src/services/store.ts b/modules/server-node/src/services/store.ts index b8c976445..c42d6941b 100644 --- a/modules/server-node/src/services/store.ts +++ b/modules/server-node/src/services/store.ts @@ -18,6 +18,7 @@ import { GetTransfersFilterOpts, StoredTransactionAttempt, StoredTransactionReceipt, + ChannelUpdate, } from "@connext/vector-types"; import { getRandomBytes32, getSignerAddressFromPublicIdentifier, mkSig } from "@connext/vector-utils"; import { BigNumber } from "@ethersproject/bignumber"; @@ -88,6 +89,71 @@ const convertOnchainTransactionEntityToTransaction = ( }; }; +const convertUpdateEntityToChannelUpdate = (entity: Update & { channel: Channel | null }): ChannelUpdate => { + let details: SetupUpdateDetails | DepositUpdateDetails | CreateUpdateDetails | ResolveUpdateDetails | undefined; + switch (entity.type) { + case "setup": + details = { + networkContext: { + chainId: BigNumber.from(entity.channel!.chainId).toNumber(), + channelFactoryAddress: entity.channel!.channelFactoryAddress, + transferRegistryAddress: entity.channel!.transferRegistryAddress, + }, + timeout: entity.channel!.timeout, + } as SetupUpdateDetails; + break; + case "deposit": + details = { + totalDepositsAlice: entity.totalDepositsAlice, + totalDepositsBob: entity.totalDepositsBob, + } as DepositUpdateDetails; + break; + case "create": + details = { + balance: { + to: [entity.transferToA!, entity.transferToB!], + amount: [entity.transferAmountA!, entity.transferAmountB!], + }, + merkleRoot: entity.merkleRoot!, + transferDefinition: entity.transferDefinition!, + transferTimeout: entity.transferTimeout!, + transferId: entity.transferId!, + transferEncodings: entity.transferEncodings!.split("$"), + transferInitialState: JSON.parse(entity.transferInitialState!), + meta: entity.meta ? JSON.parse(entity.meta) : undefined, + } as CreateUpdateDetails; + break; + case "resolve": + details = { + merkleRoot: entity.merkleRoot!, + transferDefinition: entity.transferDefinition!, + transferId: entity.transferId!, + transferResolver: JSON.parse(entity.transferResolver!), + meta: entity.meta ? JSON.parse(entity.meta) : undefined, + } as ResolveUpdateDetails; + break; + } + return { + id: { + id: entity.id!, + signature: entity.idSignature!, + }, + assetId: entity.assetId, + balance: { + amount: [entity.amountA, entity.amountB], + to: [entity.toA, entity.toB], + }, + channelAddress: entity.channelAddressId, + details, + fromIdentifier: entity.fromIdentifier, + nonce: entity.nonce, + aliceSignature: entity.signatureA ?? undefined, + bobSignature: entity.signatureB ?? undefined, + toIdentifier: entity.toIdentifier, + type: entity.type as keyof typeof UpdateType, + }; +}; + const convertChannelEntityToFullChannelState = ( channelEntity: Channel & { balances: BalanceEntity[]; @@ -119,52 +185,9 @@ const convertChannelEntityToFullChannelState = ( }); // convert db representation into details for the particular update - let details: SetupUpdateDetails | DepositUpdateDetails | CreateUpdateDetails | ResolveUpdateDetails | undefined; - if (channelEntity.latestUpdate) { - switch (channelEntity.latestUpdate.type) { - case "setup": - details = { - networkContext: { - chainId: BigNumber.from(channelEntity.chainId).toNumber(), - channelFactoryAddress: channelEntity.channelFactoryAddress, - transferRegistryAddress: channelEntity.transferRegistryAddress, - }, - timeout: channelEntity.timeout, - } as SetupUpdateDetails; - break; - case "deposit": - details = { - totalDepositsAlice: channelEntity.latestUpdate.totalDepositsAlice, - totalDepositsBob: channelEntity.latestUpdate.totalDepositsBob, - } as DepositUpdateDetails; - break; - case "create": - details = { - balance: { - to: [channelEntity.latestUpdate.transferToA!, channelEntity.latestUpdate.transferToB!], - amount: [channelEntity.latestUpdate.transferAmountA!, channelEntity.latestUpdate.transferAmountB!], - }, - merkleProofData: channelEntity.latestUpdate.merkleProofData!.split(","), - merkleRoot: channelEntity.latestUpdate.merkleRoot!, - transferDefinition: channelEntity.latestUpdate.transferDefinition!, - transferTimeout: channelEntity.latestUpdate.transferTimeout!, - transferId: channelEntity.latestUpdate.transferId!, - transferEncodings: channelEntity.latestUpdate.transferEncodings!.split("$"), - transferInitialState: JSON.parse(channelEntity.latestUpdate.transferInitialState!), - meta: channelEntity.latestUpdate!.meta ? JSON.parse(channelEntity.latestUpdate!.meta) : undefined, - } as CreateUpdateDetails; - break; - case "resolve": - details = { - merkleRoot: channelEntity.latestUpdate.merkleRoot!, - transferDefinition: channelEntity.latestUpdate.transferDefinition!, - transferId: channelEntity.latestUpdate.transferId!, - transferResolver: JSON.parse(channelEntity.latestUpdate.transferResolver!), - meta: channelEntity.latestUpdate!.meta ? JSON.parse(channelEntity.latestUpdate!.meta) : undefined, - } as ResolveUpdateDetails; - break; - } - } + const latestUpdate = !!channelEntity.latestUpdate + ? convertUpdateEntityToChannelUpdate({ ...channelEntity.latestUpdate, channel: channelEntity }) + : undefined; const channel: FullChannelState = { assetIds, @@ -185,21 +208,7 @@ const convertChannelEntityToFullChannelState = ( bob: channelEntity.participantB, bobIdentifier: channelEntity.publicIdentifierB, timeout: channelEntity.timeout, - latestUpdate: { - assetId: channelEntity.latestUpdate!.assetId, - balance: { - amount: [channelEntity.latestUpdate!.amountA, channelEntity.latestUpdate!.amountB], - to: [channelEntity.latestUpdate!.toA, channelEntity.latestUpdate!.toB], - }, - channelAddress: channelEntity.channelAddress, - details, - fromIdentifier: channelEntity.latestUpdate!.fromIdentifier, - nonce: channelEntity.latestUpdate!.nonce, - aliceSignature: channelEntity.latestUpdate!.signatureA ?? undefined, - bobSignature: channelEntity.latestUpdate!.signatureB ?? undefined, - toIdentifier: channelEntity.latestUpdate!.toIdentifier, - type: channelEntity.latestUpdate!.type as "create" | "deposit" | "resolve" | "setup", - }, + latestUpdate: latestUpdate as any, inDispute: !!channelEntity.dispute, }; return channel; @@ -643,6 +652,14 @@ export class PrismaStore implements IServerNodeStore { await this.prisma.$disconnect(); } + async getUpdateById(id: string): Promise { + const entity = await this.prisma.update.findUnique({ where: { id }, include: { channel: true } }); + if (!entity) { + return undefined; + } + return convertUpdateEntityToChannelUpdate(entity); + } + async getChannelState(channelAddress: string): Promise { const channelEntity = await this.prisma.channel.findUnique({ where: { channelAddress }, @@ -817,7 +834,6 @@ export class PrismaStore implements IServerNodeStore { (channelState.latestUpdate!.details as CreateUpdateDetails).balance?.amount[1] ?? undefined, transferToB: (channelState.latestUpdate!.details as CreateUpdateDetails).balance?.to[1] ?? undefined, merkleRoot: (channelState.latestUpdate!.details as CreateUpdateDetails).merkleRoot, - merkleProofData: (channelState.latestUpdate!.details as CreateUpdateDetails).merkleProofData?.join(), transferDefinition: (channelState.latestUpdate!.details as CreateUpdateDetails).transferDefinition, transferEncodings: (channelState.latestUpdate!.details as CreateUpdateDetails).transferEncodings ? (channelState.latestUpdate!.details as CreateUpdateDetails).transferEncodings.join("$") // comma separation doesnt work @@ -834,6 +850,8 @@ export class PrismaStore implements IServerNodeStore { : undefined, }, create: { + id: channelState.latestUpdate.id.id, + idSignature: channelState.latestUpdate.id.signature, channelAddressId: channelState.channelAddress, channel: { connect: { channelAddress: channelState.channelAddress } }, fromIdentifier: channelState.latestUpdate.fromIdentifier, @@ -865,7 +883,6 @@ export class PrismaStore implements IServerNodeStore { (channelState.latestUpdate!.details as CreateUpdateDetails).balance?.amount[1] ?? undefined, transferToB: (channelState.latestUpdate!.details as CreateUpdateDetails).balance?.to[1] ?? undefined, merkleRoot: (channelState.latestUpdate!.details as CreateUpdateDetails).merkleRoot, - merkleProofData: (channelState.latestUpdate!.details as CreateUpdateDetails).merkleProofData?.join(), transferDefinition: (channelState.latestUpdate!.details as CreateUpdateDetails).transferDefinition, transferEncodings: (channelState.latestUpdate!.details as CreateUpdateDetails).transferEncodings ? (channelState.latestUpdate!.details as CreateUpdateDetails).transferEncodings.join("$") // comma separation doesnt work @@ -943,6 +960,8 @@ export class PrismaStore implements IServerNodeStore { let latestUpdateModel: Prisma.UpdateCreateInput | undefined; if (channel.latestUpdate) { latestUpdateModel = { + id: channel.latestUpdate.id.id, + idSignature: channel.latestUpdate.id.signature, channelAddressId: channel.channelAddress, fromIdentifier: channel.latestUpdate!.fromIdentifier, toIdentifier: channel.latestUpdate!.toIdentifier, @@ -971,7 +990,6 @@ export class PrismaStore implements IServerNodeStore { transferAmountB: (channel.latestUpdate!.details as CreateUpdateDetails).balance?.amount[1] ?? undefined, transferToB: (channel.latestUpdate!.details as CreateUpdateDetails).balance?.to[1] ?? undefined, merkleRoot: (channel.latestUpdate!.details as CreateUpdateDetails).merkleRoot, - merkleProofData: (channel.latestUpdate!.details as CreateUpdateDetails).merkleProofData?.join(), transferDefinition: (channel.latestUpdate!.details as CreateUpdateDetails).transferDefinition, transferEncodings: (channel.latestUpdate!.details as CreateUpdateDetails).transferEncodings ? (channel.latestUpdate!.details as CreateUpdateDetails).transferEncodings.join("$") // comma separation doesnt work @@ -1025,7 +1043,6 @@ export class PrismaStore implements IServerNodeStore { transferTimeout: transfer.transferTimeout, transferInitialState: JSON.stringify(transfer.transferState), transferEncodings: transfer.transferEncodings.join("$"), - merkleProofData: "", // could recreate, but y tho meta: transfer.meta ? JSON.stringify(transfer.meta) : undefined, responder: transfer.responder, }, diff --git a/modules/test-runner/ops/webpack.config.js b/modules/test-runner/ops/webpack.config.js index 43ea02a2c..be0ed09e1 100644 --- a/modules/test-runner/ops/webpack.config.js +++ b/modules/test-runner/ops/webpack.config.js @@ -72,6 +72,10 @@ module.exports = { from: path.join(__dirname, "../node_modules/@connext/vector-contracts/dist/pure-evm_bg.wasm"), to: path.join(__dirname, "../dist/pure-evm_bg.wasm"), }, + { + from: path.join(__dirname, "../../../node_modules/@connext/vector-merkle-tree/dist/node/index_bg.wasm"), + to: path.join(__dirname, "../dist/index_bg.wasm"), + }, ], }), ], diff --git a/modules/test-runner/package.json b/modules/test-runner/package.json index f4a8421b3..78ea086ac 100644 --- a/modules/test-runner/package.json +++ b/modules/test-runner/package.json @@ -14,10 +14,11 @@ "author": "", "license": "ISC", "dependencies": { - "@connext/vector-contracts": "0.2.5-beta.18", - "@connext/vector-types": "0.2.5-beta.18", - "@connext/vector-utils": "0.2.5-beta.18", - "@ethereum-waffle/chai": "3.3.0", + "@connext/vector-merkle-tree": "0.1.4", + "@ethereum-waffle/chai": "3.3.1", + "@connext/vector-contracts": "0.3.0-beta.2", + "@connext/vector-types": "0.3.0-beta.2", + "@connext/vector-utils": "0.3.0-beta.2", "@types/chai": "4.2.15", "@types/chai-as-promised": "7.1.3", "@types/chai-subset": "1.3.3", diff --git a/modules/test-runner/src/duet/eventSetup.ts b/modules/test-runner/src/duet/eventSetup.ts index 02fb0785d..b6096f5ab 100644 --- a/modules/test-runner/src/duet/eventSetup.ts +++ b/modules/test-runner/src/duet/eventSetup.ts @@ -10,6 +10,7 @@ import { WithdrawalReconciledPayload, WithdrawalResolvedPayload, ConditionalTransferRoutingCompletePayload, + RunAuctionPayload, } from "@connext/vector-types"; import { env } from "../utils"; @@ -22,6 +23,7 @@ const depositReconciledPath = "/deposit-reconciled"; const withdrawalCreatedPath = "/withdrawal-created"; const withdrawalResolvedPath = "/withdrawal-resolved"; const withdrawalReconciledPath = "/withdrawal-reconciled"; +const runAuctionPath = "/run-auction"; export const aliceEvts = { [EngineEvents.IS_ALIVE]: {}, [EngineEvents.SETUP]: {}, @@ -55,6 +57,10 @@ export const aliceEvts = { evt: Evt.create(), url: `${serverBase}${depositReconciledPath}-alice`, }, + [EngineEvents.RUN_AUCTION_EVENT]: { + evt: Evt.create(), + url: `${serverBase}${runAuctionPath}-alice`, + }, [ChainServiceEvents.TRANSACTION_SUBMITTED]: {}, [ChainServiceEvents.TRANSACTION_MINED]: {}, [ChainServiceEvents.TRANSACTION_FAILED]: {}, @@ -97,6 +103,10 @@ export const bobEvts = { evt: Evt.create(), url: `${serverBase}${depositReconciledPath}-bob`, }, + [EngineEvents.RUN_AUCTION_EVENT]: { + evt: Evt.create(), + url: `${serverBase}${runAuctionPath}-bob`, + }, [ChainServiceEvents.TRANSACTION_SUBMITTED]: {}, [ChainServiceEvents.TRANSACTION_MINED]: {}, [ChainServiceEvents.TRANSACTION_FAILED]: {}, diff --git a/modules/test-runner/src/load/helpers/agent.ts b/modules/test-runner/src/load/helpers/agent.ts index 9ab7f0b5f..8256847ed 100644 --- a/modules/test-runner/src/load/helpers/agent.ts +++ b/modules/test-runner/src/load/helpers/agent.ts @@ -23,7 +23,7 @@ const provider = new providers.JsonRpcProvider(env.chainProviders[chainId]); const wallet = Wallet.fromMnemonic(env.sugarDaddy).connect(provider); const transferAmount = "1"; //utils.parseEther("0.00001").toString(); const agentBalance = utils.parseEther("0.0005").toString(); -const routerBalance = utils.parseEther("0.15"); +const routerBalance = utils.parseEther("0.3"); const walletQueue = new PriorityQueue({ concurrency: 1 }); @@ -467,7 +467,7 @@ export class AgentManager { logger.info({ transferId, channelAddress, agent: agent.publicIdentifier, routingId }, "Resolved transfer"); } catch (e) { logger.error( - { transferId, channelAddress, agent: agent.publicIdentifier, error: e.message }, + { transferId, channelAddress, agent: agent.publicIdentifier, error: e }, "Failed to resolve transfer", ); process.exit(1); @@ -508,7 +508,8 @@ export class AgentManager { this.transferInfo[routingId].end = Date.now(); // If it was cancelled, mark as failure - if (Object.values(data.transfer.transferResolver)[0] === constants.HashZero) { + const cancelled = Object.values(data.transfer.transferResolver)[0] === constants.HashZero; + if (cancelled) { logger.warn( { transferId: transfer.transferId, @@ -530,7 +531,7 @@ export class AgentManager { } // Only create a new transfer IFF you resolved it - if (agent.signerAddress === transfer.initiator) { + if (agent.signerAddress === transfer.initiator && !cancelled) { logger.debug( { transfer: transfer.transferId, @@ -675,7 +676,7 @@ export class AgentManager { const errored = Object.entries(this.transferInfo) .map(([routingId, transfer]) => { if (transfer.error) { - return transfer.error; + return { ...transfer, routingId }; } return undefined; }) @@ -690,6 +691,9 @@ export class AgentManager { created: Object.entries(this.transferInfo).length, completed: times.length, cancelled: errored.length, + cancellationReasons: errored.map((c) => { + return { routingId: c!.routingId, reason: c!.error }; + }), }, "Transfer summary", ); diff --git a/modules/test-runner/src/load/helpers/setupServer.ts b/modules/test-runner/src/load/helpers/setupServer.ts index eb350ae3e..1d24906c0 100644 --- a/modules/test-runner/src/load/helpers/setupServer.ts +++ b/modules/test-runner/src/load/helpers/setupServer.ts @@ -64,6 +64,7 @@ export const carolEvts = { [EngineEvents.CHANNEL_DEFUNDED]: {}, [EngineEvents.TRANSFER_DISPUTED]: {}, [EngineEvents.TRANSFER_DEFUNDED]: {}, + [EngineEvents.RUN_AUCTION_EVENT]: {}, }; export const logger = pino({ level: "info" }); diff --git a/modules/test-runner/src/load/helpers/test.ts b/modules/test-runner/src/load/helpers/test.ts index 5417e07de..248f00813 100644 --- a/modules/test-runner/src/load/helpers/test.ts +++ b/modules/test-runner/src/load/helpers/test.ts @@ -134,7 +134,9 @@ export const concurrencyTest = async (): Promise => { const resolved = completed.filter((x) => !!x) as TransferCompletedPayload[]; const cancelled = resolved.filter((c) => c.cancelled); loopStats = { - cancellationReasons: cancelled.map((c) => c.cancellationReason), + cancellationReasons: cancelled.map((c) => { + return { id: c.transferId, reason: c.cancellationReason }; + }), cancelled: cancelled.length, resolved: resolved.length, concurrency, diff --git a/modules/test-runner/src/trio/eventSetup.ts b/modules/test-runner/src/trio/eventSetup.ts index a15ba6d7e..a4669037b 100644 --- a/modules/test-runner/src/trio/eventSetup.ts +++ b/modules/test-runner/src/trio/eventSetup.ts @@ -12,6 +12,7 @@ import { ChannelDisputedPayload, ChannelDefundedPayload, ConditionalTransferRoutingCompletePayload, + RunAuctionPayload, } from "@connext/vector-types"; import { env } from "../utils"; @@ -19,13 +20,14 @@ import { env } from "../utils"; const serverBase = `http://${env.testerName}:${env.port}`; const conditionalTransferCreatedPath = "/conditional-transfer-created"; const conditionalTransferResolvedPath = "/conditional-transfer-resolved"; -const conditionalTransferForwardedPath = "/conditional-transfer-forwarded"; +const conditionalTransferForwardedPath = "/conditional-transfer-routing-complete"; const depositReconciledPath = "/deposit-reconciled"; const withdrawalCreatedPath = "/withdrawal-created"; const withdrawalResolvedPath = "/withdrawal-resolved"; const withdrawalReconciledPath = "/withdrawal-reconciled"; const channelDisputedPath = "/channel-disputed"; const channelDefundedPath = "/channel-defunded"; +const runAuctionPath = "/run-auction"; export const carolEvts = { [EngineEvents.IS_ALIVE]: {}, [EngineEvents.SETUP]: {}, @@ -59,6 +61,10 @@ export const carolEvts = { evt: Evt.create(), url: `${serverBase}${depositReconciledPath}-carol`, }, + [EngineEvents.RUN_AUCTION_EVENT]: { + evt: Evt.create(), + url: `${serverBase}${runAuctionPath}-carol`, + }, [ChainServiceEvents.TRANSACTION_SUBMITTED]: {}, [ChainServiceEvents.TRANSACTION_MINED]: {}, [ChainServiceEvents.TRANSACTION_FAILED]: {}, @@ -107,6 +113,10 @@ export const daveEvts = { evt: Evt.create(), url: `${serverBase}${depositReconciledPath}-dave`, }, + [EngineEvents.RUN_AUCTION_EVENT]: { + evt: Evt.create(), + url: `${serverBase}${runAuctionPath}-dave`, + }, [ChainServiceEvents.TRANSACTION_SUBMITTED]: {}, [ChainServiceEvents.TRANSACTION_MINED]: {}, [ChainServiceEvents.TRANSACTION_FAILED]: {}, diff --git a/modules/test-runner/src/trio/happy.test.ts b/modules/test-runner/src/trio/happy.test.ts index 961454099..35b6e0f74 100644 --- a/modules/test-runner/src/trio/happy.test.ts +++ b/modules/test-runner/src/trio/happy.test.ts @@ -1,4 +1,4 @@ -import { delay, expect, getRandomBytes32, RestServerNodeService } from "@connext/vector-utils"; +import { createlockHash, delay, expect, getRandomBytes32, RestServerNodeService } from "@connext/vector-utils"; import { Wallet, utils, constants } from "ethers"; import pino from "pino"; import { EngineEvents, INodeService, TransferNames } from "@connext/vector-types"; @@ -253,4 +253,77 @@ describe(testName, () => { Wallet.createRandom().address, ); }); + + // NOTE: will need to bump timeout for + // this test to run + it.skip("should work for 1000s of transfers", async () => { + const assetId = constants.AddressZero; + const depositAmt = utils.parseEther("0.2"); + const transferAmt = utils.parseEther("0.00000001"); + const numberOfTransfers = 5_000; + + const carolRogerPostSetup = await setup(carolService, rogerService, chainId1); + const daveRogerPostSetup = await setup(daveService, rogerService, chainId1); + + // carol deposits + await deposit(carolService, rogerService, carolRogerPostSetup.channelAddress, assetId, depositAmt); + + let recievedTransfers = 0; + daveService.on(EngineEvents.CONDITIONAL_TRANSFER_CREATED, (data) => { + recievedTransfers++; + }); + + let forwardedTransfers = 0; + carolService.on(EngineEvents.CONDITIONAL_TRANSFER_ROUTING_COMPLETE, (data) => { + forwardedTransfers++; + }); + + let requests = 0; + const completed = new Promise(async (resolve) => { + while (recievedTransfers < numberOfTransfers) { + if (requests !== numberOfTransfers) { + await delay(35_000); + continue; + } else { + console.log(`recipient has ${recievedTransfers + 1} / ${numberOfTransfers}`); + await delay(1_000); + } + } + resolve(undefined); + }); + + let t1; + let t10: number[] = []; + for (const _ of Array(numberOfTransfers).fill(0)) { + t1 = Date.now(); + const res = await carolService.conditionalTransfer({ + publicIdentifier: carolService.publicIdentifier, + channelAddress: carolRogerPostSetup.channelAddress, + amount: transferAmt.toString(), + assetId, + type: TransferNames.HashlockTransfer, + details: { + lockHash: createlockHash(getRandomBytes32()), + expiry: "0", + }, + recipient: daveService.publicIdentifier, + }); + + if (res.isError) { + throw res.getError(); + } + + requests++; + const diff = Date.now() - t1; + t10.push(diff); + if (requests % 10 === 0) { + console.log( + `${requests}/${numberOfTransfers} created ${diff} ${t10.reduce((prev: number, curr: number) => prev + curr)}`, + ); + t10 = []; + } + } + console.log("created all transfers"); + await completed; + }); }); diff --git a/modules/test-ui/ops/config-overrides.js b/modules/test-ui/ops/config-overrides.js new file mode 100644 index 000000000..a7b3b2326 --- /dev/null +++ b/modules/test-ui/ops/config-overrides.js @@ -0,0 +1,29 @@ +// Goal: add wasm support to a create-react-app +// Solution derived from: https://stackoverflow.com/a/61722010 + +const path = require("path"); + +module.exports = function override(config, env) { + const wasmExtensionRegExp = /\.wasm$/; + + config.resolve.extensions.push(".wasm"); + + // make sure the file-loader ignores WASM files + config.module.rules.forEach((rule) => { + (rule.oneOf || []).forEach((oneOf) => { + if (oneOf.loader && oneOf.loader.indexOf("file-loader") >= 0) { + oneOf.exclude.push(wasmExtensionRegExp); + } + }); + }); + + // add new loader to handle WASM files + config.module.rules.push({ + include: path.resolve(__dirname, "src"), + test: wasmExtensionRegExp, + type: "webassembly/experimental", + use: [{ loader: require.resolve("wasm-loader"), options: {} }], + }); + + return config; +}; diff --git a/modules/test-ui/package.json b/modules/test-ui/package.json index 0d4db1272..f0b39db3b 100644 --- a/modules/test-ui/package.json +++ b/modules/test-ui/package.json @@ -3,9 +3,9 @@ "version": "0.0.1", "private": true, "dependencies": { - "@connext/vector-browser-node": "0.2.5-beta.18", - "@connext/vector-types": "0.2.5-beta.18", - "@connext/vector-utils": "0.2.5-beta.18", + "@connext/vector-browser-node": "0.3.0-beta.2", + "@connext/vector-types": "0.3.0-beta.2", + "@connext/vector-utils": "0.3.0-beta.2", "@types/node": "14.14.31", "@types/react": "16.9.53", "@types/react-dom": "16.9.8", @@ -14,16 +14,18 @@ "ethers": "5.2.0", "pino": "6.11.1", "react": "17.0.1", + "react-app-rewired": "2.1.8", "react-dom": "17.0.1", "react-scripts": "3.4.3", "react-copy-to-clipboard": "5.0.3", - "typescript": "4.2.4" + "typescript": "4.2.4", + "wasm-loader": "1.3.0" }, "scripts": { - "start": "react-scripts start", - "build": "react-scripts build", - "test": "react-scripts test", - "eject": "react-scripts eject" + "start": "react-app-rewired start", + "build": "react-app-rewired --max_old_space_size=4096 build", + "test": "react-app-rewired test", + "eject": "react-app-rewired eject" }, "eslintConfig": { "extends": "react-app" @@ -39,5 +41,6 @@ "last 1 firefox version", "last 1 safari version" ] - } + }, + "config-overrides-path": "ops/config-overrides" } diff --git a/modules/test-ui/src/App.tsx b/modules/test-ui/src/App.tsx index d7312bb54..d5f574bf3 100644 --- a/modules/test-ui/src/App.tsx +++ b/modules/test-ui/src/App.tsx @@ -1,849 +1,13 @@ -import { BrowserNode, NonEIP712Message } from "@connext/vector-browser-node"; -import { - getPublicKeyFromPublicIdentifier, - encrypt, - createlockHash, - getBalanceForAssetId, - getRandomBytes32, - constructRpcRequest, -} from "@connext/vector-utils"; -import React, { useState } from "react"; -import { constants, providers } from "ethers"; -import { Col, Divider, Row, Statistic, Input, Typography, Table, Form, Button, List, Select, Tabs, Radio } from "antd"; -import { CopyToClipboard } from "react-copy-to-clipboard"; -import { EngineEvents, FullChannelState, jsonifyError, TransferNames } from "@connext/vector-types"; +import React, { useEffect } from "react"; +import Auction from "./components/Auction"; +// import OldCode from "./components/OldCode"; import "./App.css"; -import { config } from "./config"; - function App() { - const [node, setNode] = useState(); - const [routerPublicIdentifier, setRouterPublicIdentifier] = useState(); - const [channels, setChannels] = useState([]); - const [selectedChannel, setSelectedChannel] = useState(); - const [showCustomIframe, setShowCustomIframe] = useState(false); - - const [setupLoading, setSetupLoading] = useState(false); - const [connectLoading, setConnectLoading] = useState(false); - const [depositLoading, setDepositLoading] = useState(false); - const [requestCollateralLoading, setRequestCollateralLoading] = useState(false); - const [transferLoading, setTransferLoading] = useState(false); - const [withdrawLoading, setWithdrawLoading] = useState(false); - const [withdrawRetryLoading, setWithdrawRetryLoading] = useState(false); - - const [connectError, setConnectError] = useState(); - const [copied, setCopied] = useState(false); - const [activeTab, setActiveTab] = useState<"HashlockTransfer" | "CrossChainTransfer">("HashlockTransfer"); - - const [withdrawForm] = Form.useForm(); - const [transferForm] = Form.useForm(); - const [signMessageForm] = Form.useForm(); - - const connectNode = async ( - iframeSrc: string, - supportedChains: number[], - _routerPublicIdentifier: string, - loginProvider: "none" | "metamask" | "magic", - ): Promise => { - try { - setConnectLoading(true); - setRouterPublicIdentifier(_routerPublicIdentifier); - const chainProviders = {}; - supportedChains.forEach((chain) => { - chainProviders[chain] = config.chainProviders[chain]; - }); - const client = new BrowserNode({ - supportedChains, - iframeSrc, - routerPublicIdentifier: _routerPublicIdentifier, - chainProviders, - chainAddresses: config.chainAddresses, - messagingUrl: config.messagingUrl, - }); - let init: { signature?: string; signer?: string } | undefined = undefined; - if (loginProvider === "magic") { - // add unsafe sig - throw new Error("MAGIC TODO"); - } - - let error: any | undefined; - try { - await client.init(init); - } catch (e) { - console.error("Error initializing Browser Node:", jsonifyError(e)); - error = e; - } - const shouldAttemptRestore = (error?.context?.validationError ?? "").includes("Channel is already setup"); - if (error && !shouldAttemptRestore) { - throw new Error(`Error initializing browser node: ${error}`); - } - - if (error && shouldAttemptRestore) { - console.warn("Attempting restore from router"); - for (const supportedChain of supportedChains) { - const channelRes = await client.getStateChannelByParticipants({ - counterparty: _routerPublicIdentifier, - chainId: supportedChain, - }); - if (channelRes.isError) { - throw channelRes.getError(); - } - if (!channelRes.getValue()) { - const restoreChannelState = await client.restoreState({ - counterpartyIdentifier: _routerPublicIdentifier, - chainId: supportedChain, - }); - if (restoreChannelState.isError) { - console.error("Could not restore state"); - throw restoreChannelState.getError(); - } - console.log("Restored state: ", restoreChannelState.getValue()); - } - } - console.warn("Restore complete, re-initing"); - await client.init(init); - } - - const channelsRes = await client.getStateChannels(); - if (channelsRes.isError) { - setConnectError(channelsRes.getError().message); - return; - } - const channelAddresses = channelsRes.getValue(); - const _channels = ( - await Promise.all( - channelAddresses.map(async (c) => { - const channelRes = await client.getStateChannel({ channelAddress: c }); - console.log("Channel found in store:", channelRes.getValue()); - const channelVal = channelRes.getValue() as FullChannelState; - return channelVal; - }), - ) - ).filter((chan) => supportedChains.includes(chan.networkContext.chainId)); - if (_channels.length > 0) { - setChannels(_channels); - setSelectedChannel(_channels[0]); - } - setNode(client); - client.on(EngineEvents.DEPOSIT_RECONCILED, async (data) => { - console.log("Received EngineEvents.DEPOSIT_RECONCILED: ", data); - await updateChannel(client, data.channelAddress); - }); - - client.on(EngineEvents.CONDITIONAL_TRANSFER_CREATED, async (data) => { - console.log("Received EngineEvents.CONDITIONAL_TRANSFER_CREATED: ", data); - if (data.transfer.responder !== client.signerAddress) { - console.log("We are not the responder"); - return; - } - if (!data.transfer.meta?.encryptedPreImage) { - console.log("No encrypted preImage attached", data.transfer); - return; - } - const rpc = constructRpcRequest<"chan_decrypt">("chan_decrypt", data.transfer.meta.encryptedPreImage); - const decryptedPreImage = await client.send(rpc); - - const requestRes = await client.resolveTransfer({ - channelAddress: data.transfer.channelAddress, - transferResolver: { - preImage: decryptedPreImage, - }, - transferId: data.transfer.transferId, - }); - if (requestRes.isError) { - console.error("Error resolving transfer", requestRes.getError()); - } - await updateChannel(client, data.channelAddress); - }); - return client; - } catch (e) { - console.error("Error connecting node: ", e); - setConnectError(e.message); - } finally { - setConnectLoading(false); - } - }; - - const reconnectNode = async ( - supportedChains: number[], - iframeSrc = "http://localhost:3030", - _routerPublicIdentifier = "vector8Uz1BdpA9hV5uTm6QUv5jj1PsUyCH8m8ciA94voCzsxVmrBRor", - ) => { - setRouterPublicIdentifier(_routerPublicIdentifier); - setConnectLoading(true); - try { - const chainProviders = {}; - supportedChains.forEach((chainId) => { - chainProviders[chainId.toString()] = config.chainProviders[chainId.toString()]; - }); - console.error("creating new browser node on", supportedChains, "with providers", chainProviders); - const client = new BrowserNode({ - supportedChains, - iframeSrc, - routerPublicIdentifier: _routerPublicIdentifier, - chainProviders, - }); - await client.init(); - setNode(client); - } catch (e) { - setConnectError(e.message); - } - setConnectLoading(false); - }; - - const updateChannel = async (node: BrowserNode, channelAddress: string) => { - const res = await node.getStateChannel({ channelAddress }); - if (res.isError) { - console.error("Error getting state channel", res.getError()); - } else { - console.log("Updated channel:", res.getValue()); - const idx = channels.findIndex((c) => c.channelAddress === channelAddress); - channels.splice(idx, 0, res.getValue()); - setChannels(channels); - } - }; - - const setupChannel = async (aliceIdentifier: string, chainId: number) => { - setSetupLoading(true); - const setupRes = await node.setup({ - counterpartyIdentifier: aliceIdentifier, - chainId, - timeout: "100000", - }); - if (setupRes.isError) { - console.error(setupRes.getError()); - } else { - channels.push(setupRes.getValue() as FullChannelState); - setChannels(channels); - } - setSetupLoading(false); - }; - - const reconcileDeposit = async (assetId: string) => { - setDepositLoading(true); - const depositRes = await node.reconcileDeposit({ - channelAddress: selectedChannel.channelAddress, - assetId, - }); - if (depositRes.isError) { - console.error("Error depositing", depositRes.getError()); - } - setDepositLoading(false); - }; - - const requestCollateral = async (assetId: string) => { - setRequestCollateralLoading(true); - const requestRes = await node.requestCollateral({ - channelAddress: selectedChannel.channelAddress, - assetId, - }); - if (requestRes.isError) { - console.error("Error depositing", requestRes.getError()); - } - setRequestCollateralLoading(false); - }; - - const transfer = async (assetId: string, amount: string, recipient: string, preImage: string) => { - setTransferLoading(true); - - const submittedMeta: { encryptedPreImage?: string } = {}; - if (recipient) { - const recipientPublicKey = getPublicKeyFromPublicIdentifier(recipient); - const encryptedPreImage = await encrypt(preImage, recipientPublicKey); - submittedMeta.encryptedPreImage = encryptedPreImage; - } - - const requestRes = await node.conditionalTransfer({ - type: TransferNames.HashlockTransfer, - channelAddress: selectedChannel.channelAddress, - assetId, - amount, - recipient, - details: { - lockHash: createlockHash(preImage), - expiry: "0", - }, - meta: submittedMeta, - }); - if (requestRes.isError) { - console.error("Error hashlock transferring", requestRes.getError()); - } - setTransferLoading(false); - }; - - const withdraw = async (assetId: string, amount: string, recipient: string) => { - setWithdrawLoading(true); - const requestRes = await node.withdraw({ - channelAddress: selectedChannel.channelAddress, - assetId, - amount, - recipient, - }); - if (requestRes.isError) { - console.error("Error withdrawing", requestRes.getError()); - } - setWithdrawLoading(false); - }; - - const withdrawRetry = async (transferId: string) => { - setWithdrawRetryLoading(true); - const requestRes = await node.withdrawRetry({ - channelAddress: selectedChannel.channelAddress, - transferId: transferId, - }); - if (requestRes.isError) { - console.error("Error withdrawing", requestRes.getError()); - } - setWithdrawRetryLoading(false); - }; - - const signMessage = async (message: string): Promise => { - const requestRes = await node.signUtilityMessage({ - message, - }); - if (requestRes.isError) { - console.error("Error withdrawing", requestRes.getError()); - return requestRes.getError().message; - } - return requestRes.getValue().signedMessage; - }; - - const onFinishFailed = (errorInfo: any) => { - console.log("Failed:", errorInfo); - }; - return ( -
- - - Vector Browser Node - - - Connection - - {node?.publicIdentifier ? ( - <> - - ( - - - - )} - /> - - -
{ - const iframe = showCustomIframe ? vals.customIframe : vals.iframeSrc; - console.log("Connecting to iframe at: ", iframe); - reconnectNode( - vals.supportedChains.split(",").map((x: string) => parseInt(x.trim())), - iframe, - vals.routerPublicIdentifier, - ); - }} - initialValues={{ - iframeSrc: "http://localhost:3030", - routerPublicIdentifier: "vector8Uz1BdpA9hV5uTm6QUv5jj1PsUyCH8m8ciA94voCzsxVmrBRor", - supportedChains: "1337,1338", - }} - > - - - - - {showCustomIframe && ( - - - - )} - - - - - - - - - - - - -
- - - ) : connectError ? ( - <> - - - - - ) : ( - -
{ - const iframe = showCustomIframe ? vals.customIframe : vals.iframeSrc; - console.log("Connecting to iframe at: ", iframe); - connectNode( - iframe, - vals.supportedChains.split(",").map((x: string) => parseInt(x.trim())), - vals.routerPublicIdentifier, - vals.loginProvider, - ); - }} - initialValues={{ - iframeSrc: "http://localhost:3030", - routerPublicIdentifier: "vector8Uz1BdpA9hV5uTm6QUv5jj1PsUyCH8m8ciA94voCzsxVmrBRor", - supportedChains: "1337,1338", - loginProvider: "metamask", - }} - > - - - - - {showCustomIframe && ( - - - - )} - - - - - - - - - - - - Metamask - Magic.Link - - - - - - -
- - )} -
- {node?.publicIdentifier && ( - <> - Setup Channel - - -
setupChannel(vals.counterparty, parseInt(vals.chainId))} - > - - - - - - - - - - - -
- -
- - Channels - - -
- - - -
- - - { - setCopied(true); - setTimeout(() => setCopied(false), 5000); - }} - > - - - - ChainId: {selectedChannel?.networkContext.chainId} -
- - Balance & Deposit - - {selectedChannel && selectedChannel.assetIds && ( - - { - return { - key: index, - assetId, - counterpartyBalance: selectedChannel.balances[index].amount[0], // they are Alice - myBalance: selectedChannel.balances[index].amount[1], // we are Bob - }; - })} - columns={[ - { - title: "Asset ID", - dataIndex: "assetId", - key: "assetId", - }, - { - title: "My Balance", - dataIndex: "myBalance", - key: "myBalance", - }, - { - title: "Counterparty Balance", - dataIndex: "counterpartyBalance", - key: "counterpartyBalance", - }, - ]} - /> - - )} - -
- -
- - - reconcileDeposit(assetId || constants.AddressZero)} - loading={depositLoading} - /> - - - requestCollateral(assetId || constants.AddressZero)} - loading={requestCollateralLoading} - /> - - - - - - Transfer - - - setActiveTab(active as any)}> - -
transfer(values.assetId, values.amount, values.recipient, values.preImage)} - onFinishFailed={onFinishFailed} - form={transferForm} - > - - - {/* */} - - - - - - - - { - const assetId = transferForm.getFieldValue("assetId"); - const amount = getBalanceForAssetId(selectedChannel, assetId, "bob"); - transferForm.setFieldsValue({ amount }); - }} - /> - - - - { - const preImage = getRandomBytes32(); - transferForm.setFieldsValue({ preImage }); - }} - /> - - - - - - - - - - - - - - - - - - - - - - - - -
-
- - - - Withdraw - - - withdraw(values.assetId, values.amount, values.recipient)} - onFinishFailed={onFinishFailed} - form={withdrawForm} - > - - - {/* */} - - - - - - - - { - const assetId = withdrawForm.getFieldValue("assetId"); - const amount = getBalanceForAssetId(selectedChannel, assetId, "bob"); - withdrawForm.setFieldsValue({ amount }); - }} - /> - - - - - - - - - Withdraw - - - { - const signedMessage = await signMessage(values.message); - signMessageForm.setFieldsValue({ signedMessage }); - }} - onFinishFailed={onFinishFailed} - form={signMessageForm} - > - - - - - - - - - - - - - - - - - Withdraw Retry - - - )} - + <> + + ); } diff --git a/modules/test-ui/src/components/Auction.tsx b/modules/test-ui/src/components/Auction.tsx new file mode 100644 index 000000000..116639aa9 --- /dev/null +++ b/modules/test-ui/src/components/Auction.tsx @@ -0,0 +1,776 @@ +// import { BrowserNode } from "@connext/vector-browser-node"; +// import { +// getPublicKeyFromPublicIdentifier, +// encrypt, +// createlockHash, +// getBalanceForAssetId, +// getRandomBytes32, +// constructRpcRequest, +// } from "@connext/vector-utils"; +import React, { useState, FC, useEffect } from "react"; +import { constants } from "ethers"; +import { Col, Divider, Row, Statistic, Input, Typography, Table, Form, Button, List, Select, Tabs, Radio } from "antd"; +import { CopyToClipboard } from "react-copy-to-clipboard"; +import { EngineEvents, FullChannelState, jsonifyError, TransferNames } from "@connext/vector-types"; +import { config } from "../config"; + +const Auction: FC = () => { + const [node, setNode] = useState(); + const [channels, setChannels] = useState([]); + const [selectedChannel, setSelectedChannel] = useState(); + const [showCustomIframe, setShowCustomIframe] = useState(false); + + const [connectLoading, setConnectLoading] = useState(false); + const [depositLoading, setDepositLoading] = useState(false); + const [requestCollateralLoading, setRequestCollateralLoading] = useState(false); + const [transferLoading, setTransferLoading] = useState(false); + const [withdrawLoading, setWithdrawLoading] = useState(false); + + const [connectError, setConnectError] = useState(); + const [copied, setCopied] = useState(false); + const [activeTab, setActiveTab] = useState<"HashlockTransfer" | "CrossChainTransfer">("HashlockTransfer"); + + const [withdrawForm] = Form.useForm(); + const [transferForm] = Form.useForm(); + const [signMessageForm] = Form.useForm(); + + const [utils, setUtils] = useState(); + const [browserNode, setBrowserNode] = useState(); + + const loadWasmLibs = async () => { + const utils = await import("@connext/vector-utils"); + const browserNode = await import("@connext/vector-browser-node"); + setUtils(utils); + setBrowserNode(browserNode); + }; + + useEffect(() => { + loadWasmLibs(); + }, []); + + const connectNode = async ( + iframeSrc: string, + supportedChains: number[], + loginProvider: "none" | "metamask" | "magic", + ): Promise => { + try { + setConnectLoading(true); + + const chainProviders = {}; + supportedChains.forEach((chain) => { + chainProviders[chain] = config.chainProviders[chain]; + }); + const client = new browserNode.BrowserNode({ + supportedChains, + iframeSrc, + chainProviders, + chainAddresses: config.chainAddresses, + natsUrl: "nats://18.117.154.133:4222", + authUrl: "http://18.117.154.133:5040", + messagingUrl: config.messagingUrl, + }); + let init: { signature?: string; signer?: string } | undefined = undefined; + if (loginProvider === "magic") { + // add unsafe sig + throw new Error("MAGIC TODO"); + } + + let error: any | undefined; + try { + await client.init(init); + } catch (e) { + console.error("Error initializing Browser Node:", jsonifyError(e)); + error = e; + } + setNode(client); + return client; + } catch (e) { + console.error("Error connecting node: ", e); + setConnectError(e.message); + } finally { + setConnectLoading(false); + } + }; + + const runAuction = async ( + amount: string, + chainId: number, + assetId: string, + recipientChainId: number, + recipientAssetId: string, + ) => { + const res = await node.runAuction({ + amount, + chainId, + assetId, + recipient: node.publicIdentifier, + recipientChainId, + recipientAssetId, + }); + console.log(res); + }; + + const setupChannel = async (supportedChains: number[], routerPublicIdentifier: string) => { + let error: any | undefined; + try { + await node.channelSetup({ routerPublicIdentifier: routerPublicIdentifier }); + } catch (e) { + console.error("Error initializing Browser Node:", jsonifyError(e)); + error = e; + } + const shouldAttemptRestore = (error?.context?.validationError ?? "").includes("Channel is already setup"); + if (error && !shouldAttemptRestore) { + throw new Error(`Error initializing browser node: ${error}`); + } + + if (error && shouldAttemptRestore) { + console.warn("Attempting restore from router"); + for (const supportedChain of supportedChains) { + const channelRes = await node.getStateChannelByParticipants({ + counterparty: routerPublicIdentifier, + chainId: supportedChain, + }); + if (channelRes.isError) { + throw channelRes.getError(); + } + if (!channelRes.getValue()) { + const restoreChannelState = await node.restoreState({ + counterpartyIdentifier: routerPublicIdentifier, + chainId: supportedChain, + }); + if (restoreChannelState.isError) { + console.error("Could not restore state"); + throw restoreChannelState.getError(); + } + console.log("Restored state: ", restoreChannelState.getValue()); + } + } + console.warn("Restore complete, re-initing"); + await node.channelSetup({ routerPublicIdentifier: routerPublicIdentifier }); + } + + const channelsRes = await node.getStateChannels(); + if (channelsRes.isError) { + setConnectError(channelsRes.getError().message); + return; + } + const channelAddresses = channelsRes.getValue(); + const _channels = ( + await Promise.all( + channelAddresses.map(async (c) => { + const channelRes = await node.getStateChannel({ channelAddress: c }); + console.log("Channel found in store:", channelRes.getValue()); + const channelVal = channelRes.getValue() as FullChannelState; + return channelVal; + }), + ) + ).filter((chan) => supportedChains.includes((chan as any).networkContext.chainId)); + if (_channels.length > 0) { + setChannels(_channels as any); + setSelectedChannel(_channels[0] as any); + } + + node.on(EngineEvents.DEPOSIT_RECONCILED, async (data) => { + console.log("Received EngineEvents.DEPOSIT_RECONCILED: ", data); + await updateChannel(node, data.channelAddress); + }); + + node.on(EngineEvents.CONDITIONAL_TRANSFER_CREATED, async (data) => { + console.log("Received EngineEvents.CONDITIONAL_TRANSFER_CREATED: ", data); + if (data.transfer.responder !== node.signerAddress) { + console.log("We are not the responder"); + return; + } + if (!data.transfer.meta?.encryptedPreImage) { + console.log("No encrypted preImage attached", data.transfer); + return; + } + const rpc = utils.constructRpcRequest("chan_decrypt", data.transfer.meta.encryptedPreImage); + const decryptedPreImage = await node.send(rpc); + + const requestRes = await node.resolveTransfer({ + channelAddress: data.transfer.channelAddress, + transferResolver: { + preImage: decryptedPreImage, + }, + transferId: data.transfer.transferId, + }); + if (requestRes.isError) { + console.error("Error resolving transfer", requestRes.getError()); + } + await updateChannel(node, data.channelAddress); + }); + }; + + const updateChannel = async (node: any, channelAddress: string) => { + const res = await node.getStateChannel({ channelAddress }); + if (res.isError) { + console.error("Error getting state channel", res.getError()); + } else { + console.log("Updated channel:", res.getValue()); + const idx = channels.findIndex((c) => c.channelAddress === channelAddress); + channels.splice(idx, 0, res.getValue()); + setChannels(channels); + } + }; + + const reconcileDeposit = async (assetId: string) => { + setDepositLoading(true); + const depositRes = await node.reconcileDeposit({ + channelAddress: selectedChannel.channelAddress, + assetId, + }); + if (depositRes.isError) { + console.error("Error depositing", depositRes.getError()); + } + setDepositLoading(false); + }; + + const requestCollateral = async (assetId: string) => { + setRequestCollateralLoading(true); + const requestRes = await node.requestCollateral({ + channelAddress: selectedChannel.channelAddress, + assetId, + }); + if (requestRes.isError) { + console.error("Error depositing", requestRes.getError()); + } + setRequestCollateralLoading(false); + }; + + const transfer = async (assetId: string, amount: string, recipient: string, preImage: string) => { + setTransferLoading(true); + + const submittedMeta: { encryptedPreImage?: string } = {}; + if (recipient) { + const recipientPublicKey = utils.getPublicKeyFromPublicIdentifier(recipient); + const encryptedPreImage = await utils.encrypt(preImage, recipientPublicKey); + submittedMeta.encryptedPreImage = encryptedPreImage; + } + + const requestRes = await node.conditionalTransfer({ + type: TransferNames.HashlockTransfer, + channelAddress: selectedChannel.channelAddress, + assetId, + amount, + recipient, + details: { + lockHash: utils.createlockHash(preImage), + expiry: "0", + }, + meta: submittedMeta, + }); + if (requestRes.isError) { + console.error("Error hashlock transferring", requestRes.getError()); + } + setTransferLoading(false); + }; + + const withdraw = async (assetId: string, amount: string, recipient: string) => { + setWithdrawLoading(true); + const requestRes = await node.withdraw({ + channelAddress: selectedChannel.channelAddress, + assetId, + amount, + recipient, + }); + if (requestRes.isError) { + console.error("Error withdrawing", requestRes.getError()); + } + setWithdrawLoading(false); + }; + + const signMessage = async (message: string): Promise => { + const requestRes = await node.signUtilityMessage({ + message, + }); + if (requestRes.isError) { + console.error("Error withdrawing", requestRes.getError()); + return requestRes.getError().message; + } + return requestRes.getValue().signedMessage; + }; + + const onFinishFailed = (errorInfo: any) => { + console.log("Failed:", errorInfo); + }; + + return ( +
+ +
+ Vector Browser Node + + + Connection + + {node?.publicIdentifier ? ( + <> + + ( + + + + )} + /> + + + { + runAuction(vals.amount, vals.chainId, vals.assetId, vals.recipientChainId, vals.recipientAssetId); + }} + initialValues={{ + amount: "1", + chainId: 4, + assetId: "0x0000000000000000000000000000000000000000", + recipientChainId: 5, + recipientAssetId: "0x0000000000000000000000000000000000000000", + }} + > + + + + + + + + + + + + + + + + + + + + + + + + + + + ) : connectError ? ( + <> + + + + + ) : ( + + { + const iframe = showCustomIframe ? vals.customIframe : vals.iframeSrc; + console.log("Connecting to iframe at: ", iframe); + connectNode( + iframe, + vals.supportedChains.split(",").map((x: string) => parseInt(x.trim())), + vals.loginProvider, + ); + }} + initialValues={{ + iframeSrc: "http://localhost:3030", + supportedChains: "1337,1338", + loginProvider: "metamask", + }} + > + + + + + {showCustomIframe && ( + + + + )} + + + + + + + + Metamask + Magic.Link + + + + + + + + + )} + + {node?.publicIdentifier && ( + <> + Channels + + + + + + + + + + { + setCopied(true); + setTimeout(() => setCopied(false), 5000); + }} + > + + + + ChainId: {selectedChannel?.networkContext.chainId} + + + Balance & Deposit + + {selectedChannel && selectedChannel.assetIds && ( + +
{ + return { + key: index, + assetId, + counterpartyBalance: selectedChannel.balances[index].amount[0], // they are Alice + myBalance: selectedChannel.balances[index].amount[1], // we are Bob + }; + })} + columns={[ + { + title: "Asset ID", + dataIndex: "assetId", + key: "assetId", + }, + { + title: "My Balance", + dataIndex: "myBalance", + key: "myBalance", + }, + { + title: "Counterparty Balance", + dataIndex: "counterpartyBalance", + key: "counterpartyBalance", + }, + ]} + /> + + )} + +
+ +
+ + + reconcileDeposit(assetId || constants.AddressZero)} + loading={depositLoading} + /> + + + requestCollateral(assetId || constants.AddressZero)} + loading={requestCollateralLoading} + /> + + + + + + Transfer + + + setActiveTab(active as any)}> + +
transfer(values.assetId, values.amount, values.recipient, values.preImage)} + onFinishFailed={onFinishFailed} + form={transferForm} + > + + + {/* */} + + + + + + + + { + const assetId = transferForm.getFieldValue("assetId"); + const amount = utils.getBalanceForAssetId(selectedChannel, assetId, "bob"); + transferForm.setFieldsValue({ amount }); + }} + /> + + + + { + const preImage = utils.getRandomBytes32(); + transferForm.setFieldsValue({ preImage }); + }} + /> + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + + + Withdraw + + + withdraw(values.assetId, values.amount, values.recipient)} + onFinishFailed={onFinishFailed} + form={withdrawForm} + > + + + {/* */} + + + + + + + + { + const assetId = withdrawForm.getFieldValue("assetId"); + const amount = utils.getBalanceForAssetId(selectedChannel, assetId, "bob"); + withdrawForm.setFieldsValue({ amount }); + }} + /> + + + + + + + + + Withdraw + + + { + const signedMessage = await signMessage(values.message); + signMessageForm.setFieldsValue({ signedMessage }); + }} + onFinishFailed={onFinishFailed} + form={signMessageForm} + > + + + + + + + + + + + + + + + + Withdraw Retry + + )} + + ); +}; + +export default Auction; diff --git a/modules/types/package.json b/modules/types/package.json index 1c2fdde3a..4ba24b270 100644 --- a/modules/types/package.json +++ b/modules/types/package.json @@ -1,6 +1,6 @@ { "name": "@connext/vector-types", - "version": "0.2.5-beta.18", + "version": "0.3.0-beta.2", "description": "TypeScript typings for common Connext types", "main": "dist/index.js", "module": "dist/index.esm.js", diff --git a/modules/types/src/chain.ts b/modules/types/src/chain.ts index 6048b28be..37a3d11aa 100644 --- a/modules/types/src/chain.ts +++ b/modules/types/src/chain.ts @@ -67,6 +67,7 @@ export class ChainError extends VectorError { TxReverted: "Transaction reverted on chain", MaxGasPriceReached: "Max gas price reached", ConfirmationTimeout: "Timed out waiting for confirmation.", + NonceExpired: "Failed to confirm a tx whose nonce had expired.", }; // Errors you would see from trying to send a transaction, and diff --git a/modules/types/src/channel.ts b/modules/types/src/channel.ts index 5c57622b1..80c9ff45b 100644 --- a/modules/types/src/channel.ts +++ b/modules/types/src/channel.ts @@ -61,11 +61,36 @@ export interface UpdateParamsMap { [UpdateType.setup]: SetupParams; } +// Not exactly a channel update, but another protocol method +export type RestoreParams = { + counterpartyIdentifier: string; + chainId: number; +}; + +// When generating an update from params, you need to create an +// identifier to make sure the update remains idempotent. Imagine +// without this and you are trying to apply a `create` update. +// In this case, there is no way to know whether or not you have +// already created the transfer (the `transferId` is not generated +// until you know the nonce the proposed update is executed at). +// This leads to an edgecase where a transfer is created by someone +// who does not hold priority, and installed by the responder. The +// responder then inserts their own update (thereby cancelling yours) +// and you reinsert your "create" update into the queue (causing the +// same transfer to be created 2x). You sign the update identifier so +// you dont run into this problem again when syncing an update and the +// id has been tampered with. +export type UpdateIdentifier = { + id: string; + signature: string; +}; + // Protocol update -export type UpdateParams = { +export type UpdateParams = { channelAddress: string; type: T; details: UpdateParamsMap[T]; + id: UpdateIdentifier; }; export type Balance = { @@ -172,6 +197,7 @@ export type NetworkContext = ContractAddresses & { }; export type ChannelUpdate = { + id: UpdateIdentifier; // signed by update.fromIdentifier channelAddress: string; fromIdentifier: string; toIdentifier: string; @@ -201,7 +227,6 @@ export type CreateUpdateDetails = { transferTimeout: string; transferInitialState: TransferState; transferEncodings: string[]; // Included for `applyUpdate` - merkleProofData: string[]; merkleRoot: string; meta?: BasicMeta; }; diff --git a/modules/types/src/constants.ts b/modules/types/src/constants.ts index 69884cca1..a941f4436 100644 --- a/modules/types/src/constants.ts +++ b/modules/types/src/constants.ts @@ -31,8 +31,9 @@ export const DEFAULT_FEE_EXPIRY = 300_000; // number of confirmations for non-mainnet chains export const NUM_CONFIRMATIONS = 10; +export const TEST_CHAIN_IDS = [1337, 1338, 1340, 1341, 1342]; +export const CHAINS_WITH_ONE_CONFIRMATION = [1, ...TEST_CHAIN_IDS]; // TODO: need to stop using random chainIds in our testing, these could eventually be real chains... -export const CHAINS_WITH_ONE_CONFIRMATION = [1, 1337, 1338, 1340, 1341, 1342]; export const getConfirmationsForChain = (chainId: number): number => { return CHAINS_WITH_ONE_CONFIRMATION.includes(chainId) ? 1 : NUM_CONFIRMATIONS; }; diff --git a/modules/types/src/engine.ts b/modules/types/src/engine.ts index 201d99be7..50555141c 100644 --- a/modules/types/src/engine.ts +++ b/modules/types/src/engine.ts @@ -117,6 +117,18 @@ export type WithdrawalReconciledPayload = { export const RESTORE_STATE_EVENT = "RESTORE_STATE_EVENT"; export type RestoreStatePayload = SetupPayload; +// Emitted on Auction +export const RUN_AUCTION_EVENT = "RUN_AUCTION_EVENT"; +export type RunAuctionPayload = { + amount: string; + senderPublicIdentifier: string; + senderAssetId: string; + senderChainId: number; + receiverPublicIdentifier: string; + receiverAssetId: string; + receiverChainId: number; +}; + // Grouped event types export const EngineEvents = { [IS_ALIVE_EVENT]: IS_ALIVE_EVENT, @@ -131,6 +143,7 @@ export const EngineEvents = { [WITHDRAWAL_CREATED_EVENT]: WITHDRAWAL_CREATED_EVENT, [WITHDRAWAL_RESOLVED_EVENT]: WITHDRAWAL_RESOLVED_EVENT, [WITHDRAWAL_RECONCILED_EVENT]: WITHDRAWAL_RECONCILED_EVENT, + [RUN_AUCTION_EVENT]: RUN_AUCTION_EVENT, ...ChainServiceEvents, } as const; export type EngineEvent = typeof EngineEvents[keyof typeof EngineEvents]; @@ -147,6 +160,7 @@ export interface EngineEventMap extends ChainServiceEventMap { [WITHDRAWAL_CREATED_EVENT]: WithdrawalCreatedPayload; [WITHDRAWAL_RESOLVED_EVENT]: WithdrawalResolvedPayload; [WITHDRAWAL_RECONCILED_EVENT]: WithdrawalReconciledPayload; + [RUN_AUCTION_EVENT]: RunAuctionPayload; // Add public identifiers to transaction events [ChainServiceEvents.TRANSACTION_SUBMITTED]: ChainServiceEventMap[typeof ChainServiceEvents.TRANSACTION_SUBMITTED] & { publicIdentifier: string; diff --git a/modules/types/src/index.ts b/modules/types/src/index.ts index 5a735b06b..a9598e6bc 100644 --- a/modules/types/src/index.ts +++ b/modules/types/src/index.ts @@ -9,7 +9,6 @@ export * from "./engine"; export * from "./error"; export * from "./event"; export * from "./externalValidation"; -export * from "./lock"; export * from "./messaging"; export * from "./network"; export * from "./node"; @@ -20,3 +19,4 @@ export * from "./store"; export * from "./transferDefinitions"; export * from "./utils"; export * from "./vectorProvider"; +export * from "./version"; diff --git a/modules/types/src/lock.ts b/modules/types/src/lock.ts deleted file mode 100644 index 1a92b74db..000000000 --- a/modules/types/src/lock.ts +++ /dev/null @@ -1,20 +0,0 @@ -export type LockInformation = { - type: "acquire" | "release"; - lockName: string; - lockValue?: string; -}; - -export interface ILockService { - acquireLock( - lockName: string /* Bytes32? */, - isAlice?: boolean, - counterpartyPublicIdentifier?: string, - ): Promise; - - releaseLock( - lockName: string /* Bytes32? */, - lockValue: string, - isAlice?: boolean, - counterpartyPublicIdentifier?: string, - ): Promise; -} diff --git a/modules/types/src/messaging.ts b/modules/types/src/messaging.ts index 3895f037a..be307647a 100644 --- a/modules/types/src/messaging.ts +++ b/modules/types/src/messaging.ts @@ -1,7 +1,6 @@ import { ChannelUpdate, FullChannelState, FullTransferState } from "./channel"; -import { ConditionalTransferCreatedPayload, ConditionalTransferRoutingCompletePayload } from "./engine"; +import { ConditionalTransferRoutingCompletePayload } from "./engine"; import { EngineError, NodeError, MessagingError, ProtocolError, Result, RouterError, VectorError } from "./error"; -import { LockInformation } from "./lock"; import { EngineParams, NodeResponses } from "./schemas"; export type CheckInInfo = { channelAddress: string }; @@ -25,28 +24,19 @@ export interface IBasicMessaging { type TransferQuoteRequest = Omit; export interface IMessagingService extends IBasicMessaging { - onReceiveLockMessage( - myPublicIdentifier: string, - callback: (lockInfo: Result, from: string, inbox: string) => void, - ): Promise; - sendLockMessage( - lockInfo: Result, - to: string, - from: string, - timeout?: number, - numRetries?: number, - ): Promise>; - respondToLockMessage(inbox: string, lockInformation: Result): Promise; - onReceiveProtocolMessage( myPublicIdentifier: string, callback: ( - result: Result<{ update: ChannelUpdate; previousUpdate: ChannelUpdate }, ProtocolError>, + result: Result< + { update: ChannelUpdate; previousUpdate: ChannelUpdate; protocolVersion: string }, + ProtocolError + >, from: string, inbox: string, ) => void, ): Promise; sendProtocolMessage( + protocolVersion: string, channelUpdate: ChannelUpdate, previousUpdate?: ChannelUpdate, timeout?: number, @@ -56,11 +46,20 @@ export interface IMessagingService extends IBasicMessaging { >; respondToProtocolMessage( inbox: string, + protocolVersion: string, channelUpdate: ChannelUpdate, previousUpdate?: ChannelUpdate, ): Promise; respondWithProtocolError(inbox: string, error: ProtocolError): Promise; + // TODO: remove these! + onReceiveLockMessage( + publicIdentifier: string, + callback: (lockInfo: Result, from: string, inbox: string) => void, + ): Promise; + + respondToLockMessage(inbox: string, lockInformation: Result): Promise; + sendSetupMessage( setupInfo: Result, EngineError>, to: string, @@ -85,25 +84,18 @@ export interface IMessagingService extends IBasicMessaging { // 2. sends restore data // - counterparty responds // - restore-r restores - // - restore-r sends result (err or success) to counterparty - // - counterparty receives - // 1. releases lock sendRestoreStateMessage( - restoreData: Result<{ chainId: number } | { channelAddress: string }, EngineError>, + restoreData: Result<{ chainId: number }, ProtocolError>, to: string, from: string, timeout?: number, numRetries?: number, ): Promise< - Result<{ channel: FullChannelState; activeTransfers: FullTransferState[] } | void, EngineError | MessagingError> + Result<{ channel: FullChannelState; activeTransfers: FullTransferState[] } | void, ProtocolError | MessagingError> >; onReceiveRestoreStateMessage( publicIdentifier: string, - callback: ( - restoreData: Result<{ chainId: number } | { channelAddress: string }, EngineError>, - from: string, - inbox: string, - ) => void, + callback: (restoreData: Result<{ chainId: number }, ProtocolError>, from: string, inbox: string) => void, ): Promise; respondToRestoreStateMessage( inbox: string, @@ -185,6 +177,19 @@ export interface IMessagingService extends IBasicMessaging { ) => void, ): Promise; + publishStartAuction( + to: string, + from: string, + data: Result, + inbox: string, + ): Promise; + + onReceiveAuctionMessage( + myPublicIdentifier: string, + inbox, + callback: (runAuction: Result, from: string, inbox: string) => void, + ): Promise; + publishWithdrawalSubmittedMessage( to: string, from: string, diff --git a/modules/types/src/node.ts b/modules/types/src/node.ts index db381262b..d3e73ffe7 100644 --- a/modules/types/src/node.ts +++ b/modules/types/src/node.ts @@ -149,6 +149,10 @@ export interface INodeService { syncDisputes(params: {}): Promise>; + runAuction( + params: OptionalPublicIdentifier, + ): Promise>; + once( event: T, callback: (payload: EngineEventMap[T]) => void | Promise, diff --git a/modules/types/src/protocol.ts b/modules/types/src/protocol.ts index f82bfe0bb..39ef3d60f 100644 --- a/modules/types/src/protocol.ts +++ b/modules/types/src/protocol.ts @@ -7,6 +7,7 @@ import { SetupParams, UpdateType, FullChannelState, + RestoreParams, } from "./channel"; import { ProtocolError, Result } from "./error"; import { ProtocolEventName, ProtocolEventPayloadsMap } from "./event"; @@ -18,6 +19,7 @@ export interface IVectorProtocol { deposit(params: DepositParams): Promise>; create(params: CreateTransferParams): Promise>; resolve(params: ResolveTransferParams): Promise>; + on( event: T, callback: (payload: ProtocolEventPayloadsMap[T]) => void | Promise, @@ -41,6 +43,7 @@ export interface IVectorProtocol { getTransferState(transferId: string): Promise; getActiveTransfers(channelAddress: string): Promise; syncDisputes(): Promise; + restoreState(params: RestoreParams): Promise>; } type VectorChannelMessageData = { diff --git a/modules/types/src/schemas/basic.ts b/modules/types/src/schemas/basic.ts index 0c405069e..a097cc33d 100644 --- a/modules/types/src/schemas/basic.ts +++ b/modules/types/src/schemas/basic.ts @@ -127,7 +127,6 @@ export const TCreateUpdateDetails = Type.Object({ transferTimeout: TIntegerString, transferInitialState: TransferStateSchema, transferEncodings: TransferEncodingSchema, - merkleProofData: Type.Array(Type.String()), merkleRoot: TBytes32, meta: TBasicMeta, }); diff --git a/modules/types/src/schemas/engine.ts b/modules/types/src/schemas/engine.ts index 655c73640..d56e58963 100644 --- a/modules/types/src/schemas/engine.ts +++ b/modules/types/src/schemas/engine.ts @@ -15,6 +15,7 @@ import { WithdrawalQuoteSchema, TransferQuoteSchema, } from "./basic"; +import { ProtocolParams } from "./protocol"; //////////////////////////////////////// // Engine API Parameter schemas @@ -167,6 +168,17 @@ const WithdrawParamsSchema = Type.Object({ initiatorSubmits: Type.Optional(Type.Boolean()), }); +// Run Auction Params + +const RunAuctionParamsSchema = Type.Object({ + amount: TIntegerString, + assetId: TAddress, + chainId: TChainId, + recipient: TPublicIdentifier, + recipientChainId: TChainId, + recipientAssetId: TAddress, +}); + // Withdraw Retry engine const WithdrawRetryParamsSchema = Type.Object({ channelAddress: TAddress, @@ -228,11 +240,11 @@ const SignUtilityMessageParamsSchema = Type.Object({ // Ping-pong const SendIsAliveParamsSchema = Type.Object({ channelAddress: TAddress, skipCheckIn: Type.Boolean() }); -// Restore channel from counterparty -const RestoreStateParamsSchema = Type.Object({ - counterpartyIdentifier: TPublicIdentifier, - chainId: TChainId, -}); +// // Restore channel from counterparty +// const RestoreStateParamsSchema = Type.Object({ +// counterpartyIdentifier: TPublicIdentifier, +// chainId: TChainId, +// }); // Rpc request schema const RpcRequestEngineParamsSchema = Type.Object({ @@ -299,8 +311,8 @@ export namespace EngineParams { export const SetupSchema = SetupEngineParamsSchema; export type Setup = Static; - export const RestoreStateSchema = RestoreStateParamsSchema; - export type RestoreState = Static; + export const RestoreStateSchema = ProtocolParams.RestoreSchema; + export type RestoreState = ProtocolParams.Restore; export const DepositSchema = DepositEngineParamsSchema; export type Deposit = Static; @@ -349,4 +361,7 @@ export namespace EngineParams { export const GetWithdrawalQuoteSchema = GetWithdrawalQuoteParamsSchema; export type GetWithdrawalQuote = Static; + + export const RunAuctionSchema = RunAuctionParamsSchema; + export type RunAuction = Static; } diff --git a/modules/types/src/schemas/node.ts b/modules/types/src/schemas/node.ts index 40b584f0f..d22cf36a3 100644 --- a/modules/types/src/schemas/node.ts +++ b/modules/types/src/schemas/node.ts @@ -573,6 +573,21 @@ const PostSendIsAliveResponseSchema = { }), }; +// POST RUN AUCTION +const PostRunAuctionBodySchema = Type.Intersect([ + EngineParams.RunAuctionSchema, + Type.Object({ publicIdentifier: TPublicIdentifier }), +]); + +const PostRunAuctionResponseSchema = { + 200: Type.Object({ + routerPublicIdentifier: TPublicIdentifier, + swapRate: TIntegerString, + totalFee: TIntegerString, + quote: TransferQuoteSchema, + }), +}; + // Namespace exports // eslint-disable-next-line @typescript-eslint/no-namespace export namespace NodeParams { @@ -701,6 +716,9 @@ export namespace NodeParams { export const SubmitWithdrawalsSchema = PostAdminSubmitWithdrawalsBodySchema; export type SubmitWithdrawals = Static; + + export const RunAuctionSchema = PostRunAuctionBodySchema; + export type RunAuction = Static; } // eslint-disable-next-line @typescript-eslint/no-namespace @@ -831,4 +849,7 @@ export namespace NodeResponses { export const SubmitWithdrawalsSchema = PostAdminSubmitWithdrawalsResponseSchema; export type SubmitWithdrawals = Static; + + export const RunAuctionSchema = PostRunAuctionResponseSchema; + export type RunAuction = Static; } diff --git a/modules/types/src/schemas/protocol.ts b/modules/types/src/schemas/protocol.ts index d8e0c5fcf..178b20f17 100644 --- a/modules/types/src/schemas/protocol.ts +++ b/modules/types/src/schemas/protocol.ts @@ -5,6 +5,7 @@ import { TBalance, TBasicMeta, TBytes32, + TChainId, TIntegerString, TNetworkContext, TPublicIdentifier, @@ -52,6 +53,12 @@ const ResolveProtocolParamsSchema = Type.Object({ meta: Type.Optional(TBasicMeta), }); +// Restore +const RestoreProtocolParamsSchema = Type.Object({ + counterpartyIdentifier: TPublicIdentifier, + chainId: TChainId, +}); + // Namespace export // eslint-disable-next-line @typescript-eslint/no-namespace export namespace ProtocolParams { @@ -63,4 +70,6 @@ export namespace ProtocolParams { export type Create = Static; export const ResolveSchema = ResolveProtocolParamsSchema; export type Resolve = Static; + export const RestoreSchema = RestoreProtocolParamsSchema; + export type Restore = Static; } diff --git a/modules/types/src/store.ts b/modules/types/src/store.ts index 443629fcb..0a19a0e59 100644 --- a/modules/types/src/store.ts +++ b/modules/types/src/store.ts @@ -1,7 +1,7 @@ import { TransactionReceipt, TransactionResponse } from "@ethersproject/abstract-provider"; import { WithdrawCommitmentJson } from "./transferDefinitions/withdraw"; -import { FullTransferState, FullChannelState } from "./channel"; +import { FullTransferState, FullChannelState, ChannelUpdate } from "./channel"; import { Address } from "./basic"; import { ChannelDispute, TransferDispute } from "./dispute"; import { GetTransfersFilterOpts } from "./schemas/engine"; @@ -28,9 +28,12 @@ export interface IVectorStore { getActiveTransfers(channelAddress: string): Promise; getTransferState(transferId: string): Promise; getTransfers(filterOpts?: GetTransfersFilterOpts): Promise; + getUpdateById(id: string): Promise; // Setters saveChannelState(channelState: FullChannelState, transfer?: FullTransferState): Promise; + // Used for restore + saveChannelStateAndTransfers(channelState: FullChannelState, activeTransfers: FullTransferState[]): Promise; /** * Saves information about a channel dispute from the onchain record @@ -174,8 +177,6 @@ export interface IEngineStore extends IVectorStore, IChainServiceStore { // Setters saveWithdrawalCommitment(transferId: string, withdrawCommitment: WithdrawCommitmentJson): Promise; - // Used for restore - saveChannelStateAndTransfers(channelState: FullChannelState, activeTransfers: FullTransferState[]): Promise; } export interface IServerNodeStore extends IEngineStore { diff --git a/modules/types/src/vectorProvider.ts b/modules/types/src/vectorProvider.ts index ae0101f6b..957646ae0 100644 --- a/modules/types/src/vectorProvider.ts +++ b/modules/types/src/vectorProvider.ts @@ -48,6 +48,7 @@ export const ChannelRpcMethods = { chan_syncDisputes: "chan_syncDisputes", chan_decrypt: "chan_decrypt", chan_subscription: "chan_subscription", + chan_runAuction: "chan_runAuction", } as const; export type ChannelRpcMethod = typeof ChannelRpcMethods[keyof typeof ChannelRpcMethods]; @@ -104,6 +105,7 @@ export type ChannelRpcMethodsPayloadMap = { subscription: string; data: any; }; + [ChannelRpcMethods.chan_runAuction]: EngineParams.RunAuction; }; export type ChannelRpcMethodsResponsesMap = { @@ -155,4 +157,5 @@ export type ChannelRpcMethodsResponsesMap = { [ChannelRpcMethods.chan_syncDisputes]: any; [ChannelRpcMethods.chan_decrypt]: string; [ChannelRpcMethods.chan_subscription]: any; + [ChannelRpcMethods.chan_runAuction]: NodeResponses.RunAuction; }; diff --git a/modules/types/src/version.ts b/modules/types/src/version.ts new file mode 100644 index 000000000..38aaec749 --- /dev/null +++ b/modules/types/src/version.ts @@ -0,0 +1 @@ +export const PROTOCOL_VERSION='0.3.0-beta.2' diff --git a/modules/utils/package.json b/modules/utils/package.json index 5f1a54dc2..6b332e3ed 100644 --- a/modules/utils/package.json +++ b/modules/utils/package.json @@ -1,6 +1,6 @@ { "name": "@connext/vector-utils", - "version": "0.2.5-beta.18", + "version": "0.3.0-beta.2", "description": "Crypto & other utils for vector state channels", "main": "dist/index.js", "files": [ @@ -13,7 +13,8 @@ "test": "nyc ts-mocha --check-leaks --exit 'src/**/*.spec.ts'" }, "dependencies": { - "@connext/vector-types": "0.2.5-beta.18", + "@connext/vector-merkle-tree": "0.1.4", + "@connext/vector-types": "0.3.0-beta.2", "@ethersproject/abi": "5.2.0", "@ethersproject/abstract-provider": "5.2.0", "@ethersproject/abstract-signer": "5.2.0", @@ -27,7 +28,7 @@ "@ethersproject/strings": "5.2.0", "@ethersproject/units": "5.2.0", "@ethersproject/wallet": "5.2.0", - "@ethereum-waffle/chai": "3.3.0", + "@ethereum-waffle/chai": "3.3.1", "ajv": "6.12.6", "async-mutex": "0.3.1", "axios": "0.21.1", @@ -43,7 +44,8 @@ "merkletreejs": "0.2.18", "pino": "6.11.1", "pino-pretty": "4.6.0", - "ts-natsutil": "1.1.1" + "ts-natsutil": "1.1.1", + "uuid": "8.3.2" }, "devDependencies": { "@babel/polyfill": "7.12.1", @@ -52,6 +54,7 @@ "@types/chai-subset": "1.3.3", "@types/mocha": "8.2.1", "@types/node": "14.14.31", + "copy-webpack-plugin": "6.2.1", "mocha": "8.3.0", "nyc": "15.1.0", "sinon": "10.0.0", diff --git a/modules/utils/src/chains.json b/modules/utils/src/chains.json index 2d1b9792d..8938fec35 100644 --- a/modules/utils/src/chains.json +++ b/modules/utils/src/chains.json @@ -329,6 +329,10 @@ "0xe9e7cea3dedca5984780bafc599bd69add087d56": { "symbol": "BUSD", "mainnetEquivalent": "0x4fabb145d64652a948d72533023f6e7a623c7c53" + }, + "0x532197ec38756b9956190b845d99b4b0a88e4ca9": { + "symbol": "PAID", + "mainnetEquivalent": "0x1614f18fc94f47967a3fbe5ffcd46d4e7da3d787" } }, "rpc": [ @@ -404,7 +408,7 @@ "symbol": "BNB", "mainnetEquivalent": "0xB8c77482e45F1F44dE1745F52C74426C631bDD52" }, - "0xc825e75837a3f10e6cc7bda1b85eaac572ac3b8d": { + "0x532197ec38756b9956190b845d99b4b0a88e4ca9": { "symbol": "PAID", "mainnetEquivalent": "0x1614f18fc94f47967a3fbe5ffcd46d4e7da3d787" }, diff --git a/modules/utils/src/index.ts b/modules/utils/src/index.ts index cb7705f12..0f84fadc3 100644 --- a/modules/utils/src/index.ts +++ b/modules/utils/src/index.ts @@ -15,7 +15,6 @@ export * from "./fs"; export * from "./hexStrings"; export * from "./identifiers"; export * from "./json"; -export * from "./lock"; export * from "./fees"; export * from "./math"; export * from "./merkle"; diff --git a/modules/utils/src/lock.spec.ts b/modules/utils/src/lock.spec.ts deleted file mode 100644 index cd90d4d1e..000000000 --- a/modules/utils/src/lock.spec.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { MemoryLockService, LOCK_TTL } from "./lock"; - -import { delay, expect } from "./"; - -describe("MemoLock", () => { - describe("with a common lock", () => { - let module: MemoryLockService; - - beforeEach(async () => { - module = new MemoryLockService(); - }); - - it("should not allow locks to simultaneously access resources", async function () { - this.timeout(60_000); - const store = { test: "value" }; - const callback = async (lockName: string, wait: number = LOCK_TTL / 2) => { - await delay(wait); - store.test = lockName; - }; - const lock = await module.acquireLock("foo"); - callback("round1").then(async () => { - await module.releaseLock("foo", lock); - }); - const nextLock = await module.acquireLock("foo"); - expect(nextLock).to.not.eq(lock); - await callback("round2", LOCK_TTL / 4); - await module.releaseLock("foo", nextLock); - expect(store.test).to.be.eq("round2"); - }).timeout(); - - it("should allow locking to occur", async function () { - const lock = await module.acquireLock("foo"); - const start = Date.now(); - setTimeout(() => { - module.releaseLock("foo", lock); - }, 101); - const nextLock = await module.acquireLock("foo"); - expect(Date.now() - start).to.be.at.least(100); - await module.releaseLock("foo", nextLock); - }); - - it("should handle deadlocks", async function () { - this.timeout(60_000); - await module.acquireLock("foo"); - await delay(800); - const lock = await module.acquireLock("foo"); - await module.releaseLock("foo", lock); - }); - - it("should handle concurrent locking", async function () { - this.timeout(60_000); - const start = Date.now(); - const array = [1, 2, 3, 4]; - await Promise.all( - array.map(async (i) => { - const lock = await module.acquireLock("foo"); - await delay(800); - await module.releaseLock("foo", lock); - expect(Date.now() - start).to.be.gte(700 * i); - }), - ); - }); - }); -}); diff --git a/modules/utils/src/lock.ts b/modules/utils/src/lock.ts deleted file mode 100644 index 29bd387e3..000000000 --- a/modules/utils/src/lock.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { randomBytes } from "crypto"; - -import { ILockService } from "@connext/vector-types"; -import { Mutex, MutexInterface } from "async-mutex"; - -type InternalLock = { - lock: Mutex; - releaser: MutexInterface.Releaser; - timer: NodeJS.Timeout; - secret: string; -}; - -export const LOCK_TTL = 30_000; - -export class MemoryLockService implements ILockService { - public readonly locks: Map = new Map(); - private readonly ttl = LOCK_TTL; - - async acquireLock(lockName: string): Promise { - let lock = this.locks.get(lockName)?.lock; - if (!lock) { - lock = new Mutex(); - this.locks.set(lockName, { lock, releaser: undefined, timer: undefined, secret: undefined }); - } - - const releaser = await lock.acquire(); - const secret = this.randomValue(); - const timer = setTimeout(() => this.releaseLock(lockName, secret), this.ttl); - this.locks.set(lockName, { lock, releaser, timer, secret }); - return secret; - } - - async releaseLock(lockName: string, lockValue: string): Promise { - const lock = this.locks.get(lockName); - - if (!lock) { - throw new Error(`Can't release a lock that doesn't exist: ${lockName}`); - } - if (lockValue !== lock.secret) { - throw new Error("Incorrect lock value"); - } - - clearTimeout(lock.timer); - return lock.releaser(); - } - - private randomValue() { - return randomBytes(16).toString("hex"); - } -} diff --git a/modules/utils/src/merkle.spec.ts b/modules/utils/src/merkle.spec.ts index e9eb98d1c..f70fd202e 100644 --- a/modules/utils/src/merkle.spec.ts +++ b/modules/utils/src/merkle.spec.ts @@ -1,16 +1,25 @@ +import * as merkle from "@connext/vector-merkle-tree"; import { createCoreTransferState, expect } from "./test"; import { getRandomBytes32, isValidBytes32 } from "./hexStrings"; -import { generateMerkleTreeData } from "./merkle"; -import { HashZero } from "@ethersproject/constants"; -import { hashCoreTransferState } from "./transfers"; +import { generateMerkleRoot } from "./merkle"; +import { hashCoreTransferState, encodeCoreTransferState } from "./transfers"; import { MerkleTree } from "merkletreejs"; import { keccak256 } from "ethereumjs-util"; import { keccak256 as solidityKeccak256 } from "@ethersproject/solidity"; import { bufferify } from "./crypto"; +import { CoreTransferState } from "@connext/vector-types"; -describe("generateMerkleTreeData", () => { - const generateTransfers = (noTransfers = 1) => { +const generateMerkleTreeJs = (transfers: CoreTransferState[]) => { + const sorted = transfers.sort((a, b) => a.transferId.localeCompare(b.transferId)); + + const leaves = sorted.map((transfer) => hashCoreTransferState(transfer)); + const tree = new MerkleTree(leaves, keccak256, { sortPairs: true }); + return tree; +}; + +describe("generateMerkleRoot", () => { + const generateTransfers = (noTransfers = 1): CoreTransferState[] => { return Array(noTransfers) .fill(0) .map((_, i) => { @@ -18,24 +27,112 @@ describe("generateMerkleTreeData", () => { }); }; + const getMerkleTreeRoot = (transfers: CoreTransferState[]): string => { + const data = generateMerkleRoot(transfers); + return data; + }; + + it.skip("Is not very slow", () => { + let count = 2000; + + let start = Date.now(); + + let tree = new merkle.Tree(); + let each = Date.now(); + try { + for (let i = 0; i < count; i++) { + tree.insertHex(encodeCoreTransferState(generateTransfers(1)[0])); + let _calculated = tree.root(); + + if (i % 50 === 0) { + let now = Date.now(); + console.log("Count:", i, " ", (now - each) / 50, "ms ", (now - start) / 1000, "s"); + each = now; + } + } + } finally { + tree.free(); + } + + console.log("Time Good:", Date.now() - start); + + console.log("-------"); + + start = Date.now(); + + each = Date.now(); + const encodedTransfers = []; + for (let i = 0; i < count; i++) { + encodedTransfers.push(encodeCoreTransferState(generateTransfers(1)[0])); + + tree = new merkle.Tree(); + try { + for (let encoded of encodedTransfers) { + tree.insertHex(encoded); + } + let _calculated = tree.root(); + + if (i % 50 === 0) { + let now = Date.now(); + console.log("Count:", i, " ", (now - each) / 50, "ms ", (now - start) / 1000, "s"); + each = now; + } + } finally { + tree.free(); + } + } + + console.log("Time Some:", Date.now() - start); + + console.log("-------"); + + start = Date.now(); + + let transfers = []; + each = Date.now(); + for (let i = 0; i < count; i++) { + transfers.push(generateTransfers(1)[0]); + generateMerkleRoot(transfers); + if (i % 50 === 0) { + let now = Date.now(); + console.log("Count:", i, " ", (now - each) / 50, "ms ", (now - start) / 1000, "s"); + each = now; + } + } + console.log("Time Bad:", Date.now() - start); + }); + it("should work for a single transfer", () => { const [transfer] = generateTransfers(); - const { root, tree } = generateMerkleTreeData([transfer]); - expect(root).to.not.be.eq(HashZero); + const root = getMerkleTreeRoot([transfer]); + const tree = generateMerkleTreeJs([transfer]); + expect(root).to.be.eq(tree.getHexRoot()); expect(isValidBytes32(root)).to.be.true; const leaf = hashCoreTransferState(transfer); expect(tree.verify(tree.getHexProof(leaf), leaf, root)).to.be.true; }); + it("should generate the same root for both libs", () => { + const transfers = generateTransfers(15); + const root = getMerkleTreeRoot(transfers); + + const sorted = transfers.sort((a, b) => a.transferId.localeCompare(b.transferId)); + + const leaves = sorted.map((transfer) => hashCoreTransferState(transfer)); + const tree = new MerkleTree(leaves, keccak256, { sortPairs: true }); + expect(root).to.be.eq(tree.getHexRoot()); + }); + it("should work for multiple transfers", () => { - const transfers = generateTransfers(1); + const transfers = generateTransfers(15); const randomIdx = Math.floor(Math.random() * 1); const toProve = transfers[randomIdx]; - const { root, tree } = generateMerkleTreeData(transfers); - expect(root).to.not.be.eq(HashZero); + const root = getMerkleTreeRoot(transfers); + const tree = generateMerkleTreeJs(transfers); + expect(root).to.be.eq(tree.getHexRoot()); expect(isValidBytes32(root)).to.be.true; const leaf = hashCoreTransferState(toProve); diff --git a/modules/utils/src/merkle.ts b/modules/utils/src/merkle.ts index a3211d476..4733d6a32 100644 --- a/modules/utils/src/merkle.ts +++ b/modules/utils/src/merkle.ts @@ -1,26 +1,34 @@ +import * as merkle from "@connext/vector-merkle-tree"; import { CoreTransferState } from "@connext/vector-types"; -import { HashZero } from "@ethersproject/constants"; import { keccak256 } from "ethereumjs-util"; import { MerkleTree } from "merkletreejs"; -import { hashCoreTransferState } from "./transfers"; - -export const generateMerkleTreeData = (transfers: CoreTransferState[]): { root: string; tree: MerkleTree } => { - // Sort transfers alphabetically by id - const sorted = transfers.sort((a, b) => a.transferId.localeCompare(b.transferId)); +import { encodeCoreTransferState, hashCoreTransferState } from "./transfers"; +export const generateMerkleRoot = (transfers: CoreTransferState[]): string => { // Create leaves - const leaves = sorted.map((transfer) => { - return hashCoreTransferState(transfer); - }); + const tree = new merkle.Tree(); - // Generate tree - const tree = new MerkleTree(leaves, keccak256, { sortPairs: true }); + let root: string; + try { + transfers.forEach((transfer) => { + tree.insertHex(encodeCoreTransferState(transfer)); + }); + root = tree.root(); + } finally { + tree.free(); + } + + return root; +}; + +// Get merkle proof of transfer +// TODO: use merkle.Tree not MerkleTree +export const getMerkleProof = (active: CoreTransferState[], toProve: string): string[] => { + // Sort transfers alphabetically by id + const sorted = active.slice(0).sort((a, b) => a.transferId.localeCompare(b.transferId)); - // Return - const calculated = tree.getHexRoot(); - return { - root: calculated === "0x" ? HashZero : calculated, - tree, - }; + const leaves = sorted.map((transfer) => hashCoreTransferState(transfer)); + const tree = new MerkleTree(leaves, keccak256, { sortPairs: true }); + return tree.getHexProof(hashCoreTransferState(active.find((t) => t.transferId === toProve)!)); }; diff --git a/modules/utils/src/messaging.ts b/modules/utils/src/messaging.ts index e4baca7e8..8ddf1a16a 100644 --- a/modules/utils/src/messaging.ts +++ b/modules/utils/src/messaging.ts @@ -3,7 +3,6 @@ import { ChannelUpdate, IMessagingService, NodeError, - LockInformation, Result, EngineParams, FullChannelState, @@ -20,6 +19,7 @@ import { NATS_WS_URL, ConditionalTransferCreatedPayload, ConditionalTransferRoutingCompletePayload, + RunAuctionPayload, } from "@connext/vector-types"; import axios, { AxiosResponse } from "axios"; import pino, { BaseLogger } from "pino"; @@ -170,6 +170,13 @@ export class NatsBasicMessagingService implements IBasicMessaging { await this.connection!.publish(subject, toPublish); } + public async publishUniqueInbox(subject: string, data: any, inbox: string): Promise { + this.assertConnected(); + const toPublish = safeJsonStringify(data); + this.log.debug({ subject, data, inbox }, `Publishing`); + await this.connection!.publish(subject, toPublish, inbox); + } + public async request(subject: string, timeout: number, data: any): Promise { this.assertConnected(); this.log.debug(`Requesting ${subject} with data: ${JSON.stringify(data)}`); @@ -335,13 +342,14 @@ export class NatsMessagingService extends NatsBasicMessagingService implements I // PROTOCOL METHODS async sendProtocolMessage( + protocolVersion: string, channelUpdate: ChannelUpdate, previousUpdate?: ChannelUpdate, - timeout = 30_000, + timeout = 60_000, numRetries = 0, ): Promise; previousUpdate: ChannelUpdate }, ProtocolError>> { return this.sendMessageWithRetries( - Result.ok({ update: channelUpdate, previousUpdate }), + Result.ok({ update: channelUpdate, previousUpdate, protocolVersion }), "protocol", channelUpdate.toIdentifier, channelUpdate.fromIdentifier, @@ -354,7 +362,10 @@ export class NatsMessagingService extends NatsBasicMessagingService implements I async onReceiveProtocolMessage( myPublicIdentifier: string, callback: ( - result: Result<{ update: ChannelUpdate; previousUpdate: ChannelUpdate }, ProtocolError>, + result: Result< + { update: ChannelUpdate; previousUpdate: ChannelUpdate; protocolVersion: string }, + ProtocolError + >, from: string, inbox: string, ) => void, @@ -364,12 +375,13 @@ export class NatsMessagingService extends NatsBasicMessagingService implements I async respondToProtocolMessage( inbox: string, + protocolVersion: string, channelUpdate: ChannelUpdate, previousUpdate?: ChannelUpdate, ): Promise { return this.respondToMessage( inbox, - Result.ok({ update: channelUpdate, previousUpdate }), + Result.ok({ update: channelUpdate, previousUpdate, protocolVersion }), "respondToProtocolMessage", ); } @@ -379,14 +391,30 @@ export class NatsMessagingService extends NatsBasicMessagingService implements I } //////////// + // LOCK MESSAGE + // TODO: remove these! + async onReceiveLockMessage( + publicIdentifier: string, + callback: (lockInfo: Result, from: string, inbox: string) => void, + ): Promise { + return this.registerCallback(`${publicIdentifier}.*.lock`, callback, "onReceiveLockMessage"); + } + + async respondToLockMessage(inbox: string, lockInformation: Result): Promise { + return this.respondToMessage(inbox, lockInformation, "respondToLockMessage"); + } + + //////////// + // RESTORE METHODS async sendRestoreStateMessage( - restoreData: Result<{ chainId: number } | { channelAddress: string }, EngineError>, + restoreData: Result<{ chainId: number }, EngineError>, to: string, from: string, timeout = 30_000, numRetries?: number, ): Promise> { + this.logger.warn({ to, from, data: restoreData.toJson() }, "Sending restore message"); return this.sendMessageWithRetries( restoreData, "restore", @@ -400,19 +428,18 @@ export class NatsMessagingService extends NatsBasicMessagingService implements I async onReceiveRestoreStateMessage( publicIdentifier: string, - callback: ( - restoreData: Result<{ chainId: number } | { channelAddress: string }, EngineError>, - from: string, - inbox: string, - ) => void, + callback: (restoreData: Result<{ chainId: number }, EngineError>, from: string, inbox: string) => void, ): Promise { - await this.registerCallback(`${publicIdentifier}.*.restore`, callback, "onReceiveRestoreStateMessage"); + const subject = `${publicIdentifier}.*.restore`; + this.logger.warn({ subject }, "Registered restore state callback"); + await this.registerCallback(subject, callback, "onReceiveRestoreStateMessage"); } async respondToRestoreStateMessage( inbox: string, restoreData: Result<{ channel: FullChannelState; activeTransfers: FullTransferState[] } | void, EngineError>, ): Promise { + this.logger.warn({ inbox, data: restoreData.toJson() }, "Sending restore state response"); return this.respondToMessage(inbox, restoreData, "respondToRestoreStateMessage"); } //////////// @@ -483,29 +510,6 @@ export class NatsMessagingService extends NatsBasicMessagingService implements I } //////////// - // LOCK METHODS - async sendLockMessage( - lockInfo: Result, - to: string, - from: string, - timeout = 30_000, // TODO this timeout is copied from memolock - numRetries = 0, - ): Promise> { - return this.sendMessageWithRetries(lockInfo, "lock", to, from, timeout, numRetries, "sendLockMessage"); - } - - async onReceiveLockMessage( - publicIdentifier: string, - callback: (lockInfo: Result, from: string, inbox: string) => void, - ): Promise { - return this.registerCallback(`${publicIdentifier}.*.lock`, callback, "onReceiveLockMessage"); - } - - async respondToLockMessage(inbox: string, lockInformation: Result): Promise { - return this.respondToMessage(inbox, lockInformation, "respondToLockMessage"); - } - //////////// - // ISALIVE METHODS sendIsAliveMessage( isAlive: Result<{ channelAddress: string; skipCheckIn?: boolean }, VectorError>, @@ -635,6 +639,24 @@ export class NatsMessagingService extends NatsBasicMessagingService implements I ); } //////////// + // AUCTION METHODS + + publishStartAuction( + to: string, + from: string, + data: Result, + inbox: string, + ): Promise { + return this.publishUniqueInbox(`${from}.${to}.start-auction`, data, inbox); + } + + async onReceiveAuctionMessage( + myPublicIdentifier: string, + inbox: string, + callback: (runAuction: Result, from: string, inbox: string) => void | any, + ): Promise { + return this.registerCallback(inbox, callback, "onReceiveAuctionMessage"); + } // WITHDRAWAL SUBMITTED publishWithdrawalSubmittedMessage( diff --git a/modules/utils/src/serverNode.ts b/modules/utils/src/serverNode.ts index 7925d053c..fba9a0a82 100644 --- a/modules/utils/src/serverNode.ts +++ b/modules/utils/src/serverNode.ts @@ -492,6 +492,17 @@ export class RestServerNodeService implements INodeService { return this.executeHttpRequest(`is-alive`, "post", params, NodeParams.SendIsAliveSchema); } + async runAuction( + params: OptionalPublicIdentifier, + ): Promise> { + return this.executeHttpRequest( + `run-auction`, + "post", + params, + NodeParams.RunAuctionSchema, + ); + } + public once( event: T, callback: (payload: EngineEventMap[T]) => void | Promise, diff --git a/modules/utils/src/test/channel.ts b/modules/utils/src/test/channel.ts index 738ed3f48..7da0d9c96 100644 --- a/modules/utils/src/test/channel.ts +++ b/modules/utils/src/test/channel.ts @@ -15,6 +15,7 @@ import { FullTransferState, DEFAULT_TRANSFER_TIMEOUT, } from "@connext/vector-types"; +import { v4 as uuidV4 } from "uuid"; import { ChannelSigner } from "../channelSigner"; @@ -44,6 +45,11 @@ export function createTestUpdateParams( const base = { channelAddress: overrides.channelAddress ?? mkAddress("0xccc"), type, + id: { + id: uuidV4(), + signature: mkSig("0xcceeffaa6655"), + ...(overrides.id ?? {}), + }, }; let details: any; @@ -117,6 +123,10 @@ export function createTestChannelUpdate( bobSignature: mkSig("0x0002"), toIdentifier: mkPublicIdentifier("vectorB"), type, + id: { + id: uuidV4(), + signature: mkSig("0x00003"), + }, }; // Get details from overrides @@ -143,7 +153,6 @@ export function createTestChannelUpdate( break; case UpdateType.create: const createDeets: CreateUpdateDetails = { - merkleProofData: [mkBytes32("0xproof")], merkleRoot: mkBytes32("0xeeeeaaaaa333344444"), transferDefinition: mkAddress("0xdef"), transferId: mkBytes32("0xaaaeee"), diff --git a/modules/utils/src/test/services/index.ts b/modules/utils/src/test/services/index.ts index c28a3856f..699af3dee 100644 --- a/modules/utils/src/test/services/index.ts +++ b/modules/utils/src/test/services/index.ts @@ -1,3 +1,2 @@ -export * from "../../lock"; export * from "./messaging"; export * from "./store"; diff --git a/modules/utils/src/test/services/messaging.ts b/modules/utils/src/test/services/messaging.ts index 42116b84a..12e06b9a0 100644 --- a/modules/utils/src/test/services/messaging.ts +++ b/modules/utils/src/test/services/messaging.ts @@ -2,7 +2,6 @@ import { ChannelUpdate, IMessagingService, NodeError, - LockInformation, MessagingError, Result, FullChannelState, @@ -20,12 +19,13 @@ import { Evt } from "evt"; import { getRandomBytes32 } from "../../hexStrings"; export class MemoryMessagingService implements IMessagingService { - private readonly evt: Evt<{ + private readonly protocolEvt: Evt<{ to?: string; from: string; inbox?: string; replyTo?: string; data: { + protocolVersion?: string; update?: ChannelUpdate; previousUpdate?: ChannelUpdate; error?: ProtocolError; @@ -34,10 +34,33 @@ export class MemoryMessagingService implements IMessagingService { to?: string; from: string; inbox?: string; - data: { update?: ChannelUpdate; previousUpdate?: ChannelUpdate; error?: ProtocolError }; + data: { + update?: ChannelUpdate; + previousUpdate?: ChannelUpdate; + error?: ProtocolError; + protocolVersion?: string; + }; replyTo?: string; }>(); + private readonly restoreEvt: Evt<{ + to?: string; + from?: string; + chainId?: number; + channel?: FullChannelState; + activeTransfers?: FullTransferState[]; + error?: ProtocolError; + inbox?: string; + }> = Evt.create<{ + to?: string; + from?: string; + chainId?: number; + channel?: FullChannelState; + activeTransfers?: FullTransferState[]; + error?: ProtocolError; + inbox?: string; + }>(); + flush(): Promise { throw new Error("Method not implemented."); } @@ -47,22 +70,35 @@ export class MemoryMessagingService implements IMessagingService { } async disconnect(): Promise { - this.evt.detach(); + this.protocolEvt.detach(); + } + + // TODO: remove these! + async onReceiveLockMessage( + publicIdentifier: string, + callback: (lockInfo: Result, from: string, inbox: string) => void, + ): Promise { + console.warn("Method to be deprecated"); + } + + async respondToLockMessage(inbox: string, lockInformation: Result): Promise { + console.warn("Method to be deprecated"); } async sendProtocolMessage( + protocolVersion: string, channelUpdate: ChannelUpdate, previousUpdate?: ChannelUpdate, timeout = 20_000, numRetries = 0, ): Promise; previousUpdate: ChannelUpdate }, ProtocolError>> { const inbox = getRandomBytes32(); - const responsePromise = this.evt.pipe((e) => e.inbox === inbox).waitFor(timeout); - this.evt.post({ + const responsePromise = this.protocolEvt.pipe((e) => e.inbox === inbox).waitFor(timeout); + this.protocolEvt.post({ to: channelUpdate.toIdentifier, from: channelUpdate.fromIdentifier, replyTo: inbox, - data: { update: channelUpdate, previousUpdate }, + data: { update: channelUpdate, previousUpdate, protocolVersion }, }); const res = await responsePromise; if (res.data.error) { @@ -73,18 +109,19 @@ export class MemoryMessagingService implements IMessagingService { async respondToProtocolMessage( inbox: string, + protocolVersion: string, channelUpdate: ChannelUpdate, previousUpdate?: ChannelUpdate, ): Promise { - this.evt.post({ + this.protocolEvt.post({ inbox, - data: { update: channelUpdate, previousUpdate }, + data: { update: channelUpdate, previousUpdate, protocolVersion }, from: channelUpdate.toIdentifier, }); } async respondWithProtocolError(inbox: string, error: ProtocolError): Promise { - this.evt.post({ + this.protocolEvt.post({ inbox, data: { error }, from: error.context.update.toIdentifier, @@ -94,18 +131,22 @@ export class MemoryMessagingService implements IMessagingService { async onReceiveProtocolMessage( myPublicIdentifier: string, callback: ( - result: Result<{ update: ChannelUpdate; previousUpdate: ChannelUpdate }, ProtocolError>, + result: Result< + { update: ChannelUpdate; previousUpdate: ChannelUpdate; protocolVersion: string }, + ProtocolError + >, from: string, inbox: string, ) => void, ): Promise { - this.evt + this.protocolEvt .pipe(({ to }) => to === myPublicIdentifier) .attach(({ data, replyTo, from }) => { callback( Result.ok({ previousUpdate: data.previousUpdate!, update: data.update!, + protocolVersion: data.protocolVersion!, }), from, replyTo!, @@ -113,6 +154,59 @@ export class MemoryMessagingService implements IMessagingService { }); } + async onReceiveRestoreStateMessage( + publicIdentifier: string, + callback: (restoreData: Result<{ chainId: number }, EngineError>, from: string, inbox: string) => void, + ): Promise { + this.restoreEvt + .pipe(({ to }) => to === publicIdentifier) + .attach(({ inbox, from, chainId, error }) => { + callback(!!error ? Result.fail(error) : Result.ok({ chainId }), from, inbox); + }); + } + + async sendRestoreStateMessage( + restoreData: Result<{ chainId: number }, EngineError>, + to: string, + from: string, + timeout?: number, + numRetries?: number, + ): Promise> { + const inbox = getRandomBytes32(); + this.restoreEvt.post({ + to, + from, + error: restoreData.isError ? restoreData.getError() : undefined, + chainId: restoreData.isError ? undefined : restoreData.getValue().chainId, + inbox, + }); + try { + const response = await this.restoreEvt.waitFor((data) => { + return data.inbox === inbox; + }, timeout); + return response.error + ? Result.fail(response.error) + : Result.ok({ channel: response.channel!, activeTransfers: response.activeTransfers! }); + } catch (e) { + if (e.message.includes("Evt timeout")) { + return Result.fail(new MessagingError(MessagingError.reasons.Timeout)); + } + return Result.fail(e); + } + } + + async respondToRestoreStateMessage( + inbox: string, + restoreData: Result<{ channel: FullChannelState; activeTransfers: FullTransferState[] }, EngineError>, + ): Promise { + this.restoreEvt.post({ + inbox, + error: restoreData.getError(), + channel: restoreData.isError ? undefined : restoreData.getValue().channel, + activeTransfers: restoreData.isError ? undefined : restoreData.getValue().activeTransfers, + }); + } + sendSetupMessage( setupInfo: Result, Error>, to: string, @@ -159,51 +253,6 @@ export class MemoryMessagingService implements IMessagingService { throw new Error("Method not implemented."); } - sendRestoreStateMessage( - restoreData: Result<{ chainId: number } | { channelAddress: string }, EngineError>, - to: string, - from: string, - timeout?: number, - numRetries?: number, - ): Promise> { - throw new Error("Method not implemented."); - } - onReceiveRestoreStateMessage( - publicIdentifier: string, - callback: ( - restoreData: Result<{ chainId: number } | { channelAddress: string }, EngineError>, - from: string, - inbox: string, - ) => void, - ): Promise { - throw new Error("Method not implemented."); - } - respondToRestoreStateMessage( - inbox: string, - restoreData: Result<{ channel: FullChannelState; activeTransfers: FullTransferState[] } | void, EngineError>, - ): Promise { - throw new Error("Method not implemented."); - } - - respondToLockMessage(inbox: string, lockInformation: Result): Promise { - throw new Error("Method not implemented."); - } - onReceiveLockMessage( - myPublicIdentifier: string, - callback: (lockInfo: Result, from: string, inbox: string) => void, - ): Promise { - throw new Error("Method not implemented."); - } - sendLockMessage( - lockInfo: Result, - to: string, - from: string, - timeout?: number, - numRetries?: number, - ): Promise> { - throw new Error("Method not implemented."); - } - sendIsAliveMessage( isAlive: Result<{ channelAddress: string }, VectorError>, to: string, @@ -284,6 +333,18 @@ export class MemoryMessagingService implements IMessagingService { throw new Error("Method not implemented."); } + publishStartAuction(to: string, from: string, data: Result): Promise { + throw new Error("Method not implemented."); + } + + onReceiveAuctionMessage( + myPublicIdentifier: string, + inbox, + callback: (runAuction: Result, from: string, inbox: string) => void, + ): Promise { + throw new Error("Method not implemented."); + } + publishWithdrawalSubmittedMessage( to: string, from: string, diff --git a/modules/utils/src/test/services/store.ts b/modules/utils/src/test/services/store.ts index 0b96e0d8f..659ce0659 100644 --- a/modules/utils/src/test/services/store.ts +++ b/modules/utils/src/test/services/store.ts @@ -11,6 +11,7 @@ import { GetTransfersFilterOpts, CoreChannelState, CoreTransferState, + ChannelUpdate, } from "@connext/vector-types"; import { TransactionReceipt, TransactionResponse } from "@ethersproject/abstract-provider"; @@ -97,6 +98,7 @@ export class MemoryStoreService implements IEngineStore { // Map private channelStates: Map = new Map(); + private updates: Map = new Map(); private schemaVersion: number | undefined = undefined; @@ -118,23 +120,29 @@ export class MemoryStoreService implements IEngineStore { return Promise.resolve(); } + getUpdateById(id: string): Promise { + return Promise.resolve(this.updates.get(id)); + } + getChannelState(channelAddress: string): Promise { const state = this.channelStates.get(channelAddress); return Promise.resolve(state); } getChannelStateByParticipants( - participantA: string, - participantB: string, + publicIdentifierA: string, + publicIdentifierB: string, chainId: number, ): Promise { - return Promise.resolve( - [...this.channelStates.values()].find((channelState) => { - channelState.alice === participantA && - channelState.bob === participantB && - channelState.networkContext.chainId === chainId; - }), - ); + const channel = [...this.channelStates.values()].find((channelState) => { + const identifiers = [channelState.aliceIdentifier, channelState.bobIdentifier]; + return ( + identifiers.includes(publicIdentifierA) && + identifiers.includes(publicIdentifierB) && + channelState.networkContext.chainId === chainId + ); + }); + return Promise.resolve(channel); } getChannelStates(): Promise { @@ -142,6 +150,9 @@ export class MemoryStoreService implements IEngineStore { } saveChannelState(channelState: FullChannelState, transfer?: FullTransferState): Promise { + if (channelState.latestUpdate) { + this.updates.set(channelState.latestUpdate.id.id, channelState.latestUpdate); + } this.channelStates.set(channelState.channelAddress, { ...channelState, }); @@ -169,7 +180,24 @@ export class MemoryStoreService implements IEngineStore { } saveChannelStateAndTransfers(channelState: FullChannelState, activeTransfers: FullTransferState[]): Promise { - return Promise.reject("Method not implemented"); + // remove all previous + this.channelStates.delete(channelState.channelAddress); + activeTransfers.map((transfer) => { + this.transfers.delete(transfer.transferId); + }); + this.transfersInChannel.delete(channelState.channelAddress); + + // add in new records + this.channelStates.set(channelState.channelAddress, channelState); + activeTransfers.map((transfer) => { + this.transfers.set(transfer.transferId, transfer); + }); + this.transfersInChannel.set( + channelState.channelAddress, + activeTransfers.map((t) => t.transferId), + ); + + return Promise.resolve(); } getActiveTransfers(channelAddress: string): Promise { diff --git a/modules/utils/src/transfers.ts b/modules/utils/src/transfers.ts index 430f053cd..728d4607f 100644 --- a/modules/utils/src/transfers.ts +++ b/modules/utils/src/transfers.ts @@ -1,11 +1,9 @@ import { TransferState, CoreTransferState, - CoreTransferStateEncoding, Address, TransferResolver, Balance, - BalanceEncoding, TransferQuote, TransferQuoteEncoding, WithdrawalQuote, @@ -13,6 +11,7 @@ import { FullTransferState, } from "@connext/vector-types"; import { defaultAbiCoder } from "@ethersproject/abi"; +import { BigNumber } from "@ethersproject/bignumber"; import { keccak256 as solidityKeccak256, sha256 as soliditySha256 } from "@ethersproject/solidity"; import { keccak256 } from "ethereumjs-util"; import { bufferify } from "./crypto"; @@ -34,7 +33,16 @@ export const encodeTransferState = (state: TransferState, encoding: string): str export const decodeTransferState = (encoded: string, encoding: string): T => defaultAbiCoder.decode([encoding], encoded)[0]; -export const encodeBalance = (balance: Balance): string => defaultAbiCoder.encode([BalanceEncoding], [balance]); +export const encodeBalance = (balance: Balance): string => { + return "0x".concat( + BigNumber.from(balance.amount[0]).toHexString().slice(2).padStart(64, "0"), + BigNumber.from(balance.amount[1]).toHexString().slice(2).padStart(64, "0"), + "000000000000000000000000", + balance.to[0].slice(2), + "000000000000000000000000", + balance.to[1].slice(2), + ); +}; export const decodeTransferResolver = (encoded: string, encoding: string): T => defaultAbiCoder.decode([encoding], encoded)[0]; @@ -42,12 +50,31 @@ export const decodeTransferResolver = (encoded export const encodeTransferResolver = (resolver: TransferResolver, encoding: string): string => defaultAbiCoder.encode([encoding], [resolver]); -export const encodeCoreTransferState = (state: CoreTransferState): string => - defaultAbiCoder.encode([CoreTransferStateEncoding], [state]); +export const encodeCoreTransferState = (state: CoreTransferState): string => { + return "0x".concat( + "000000000000000000000000", + state.channelAddress.slice(2), + state.transferId.slice(2), + "000000000000000000000000", + state.transferDefinition.slice(2), + "000000000000000000000000", + state.initiator.slice(2), + "000000000000000000000000", + state.responder.slice(2), + "000000000000000000000000", + state.assetId.slice(2), + encodeBalance(state.balance).slice(2), + BigNumber.from(state.transferTimeout).toHexString().slice(2).padStart(64, "0"), + state.initialStateHash.slice(2), + ); +}; export const hashTransferState = (state: TransferState, encoding: string): string => solidityKeccak256(["bytes"], [encodeTransferState(state, encoding)]); +// export const hashCoreTransferState = (state: CoreTransferState): string => +// solidityKeccak256(["bytes"], [encodeCoreTransferState(state)]); + export const hashCoreTransferState = (state: CoreTransferState): Buffer => keccak256(bufferify(encodeCoreTransferState(state))); diff --git a/ops/npm-publish.sh b/ops/npm-publish.sh index e3e021ec8..a595b4821 100644 --- a/ops/npm-publish.sh +++ b/ops/npm-publish.sh @@ -24,8 +24,6 @@ if [[ ! "$(pwd | sed 's|.*/\(.*\)|\1|')" =~ $project ]] then echo "Aborting: Make sure you're in the $project project root" && exit 1 fi -make all - echo "Did you update the changelog.md before publishing (y/n)?" read -p "> " -r echo @@ -91,6 +89,8 @@ fi ( # () designates a subshell so we don't have to cd back to where we started afterwards echo "Let's go" + echo "export const PROTOCOL_VERSION='${target_version}'" > "${root}/modules/types/src/version.ts" + make all cd modules for package in $package_names diff --git a/ops/start-auction.sh b/ops/start-auction.sh new file mode 100644 index 000000000..8de2901fc --- /dev/null +++ b/ops/start-auction.sh @@ -0,0 +1,320 @@ +#!/usr/bin/env bash +set -e + +stack="auction" +root=$( cd "$( dirname "${BASH_SOURCE[0]}" )/.." >/dev/null 2>&1 && pwd ) +project=$(grep -m 1 '"name":' "$root/package.json" | cut -d '"' -f 4) + +# make sure a network for this project has been created +docker swarm init 2> /dev/null || true +docker network create --attachable --driver overlay "$project" 2> /dev/null || true + +if grep -qs "$stack" <<<"$(docker stack ls --format '{{.Name}}')" +then echo "A $stack stack is already running" && exit 0; +fi + +#################### +## Load Config + +if [[ ! -f "$root/node.config.json" ]] +then cp "$root/ops/config/node.default.json" "$root/node.config.json" +fi +if [[ ! -f "$root/router.config.json" ]] +then cp "$root/ops/config/router.default.json" "$root/router.config.json" +fi + +config=$( + cat "$root/node.config.json" "$root/router.config.json" \ + | jq -s '.[0] + .[1] + .[2] + .[3]' +) + +config1=$( + cat "$root/node.config.json" "$root/router1.config.json" \ + | jq -s '.[0] + .[1] + .[2] + .[3]' +) + +config2=$( + cat "$root/node.config.json" "$root/router2.config.json" \ + | jq -s '.[0] + .[1] + .[2] + .[3]' +) + +function getConfig { + value=$(echo "$config" | jq ".$1" | tr -d '"') + if [[ "$value" == "null" ]] + then echo "" + else echo "$value" + fi +} + +messaging_url=$(getConfig messagingUrl) + +chain_providers=$(echo "$config" | jq '.chainProviders' | tr -d '\n\r ') +default_providers=$(jq '.chainProviders' "$root/ops/config/node.default.json" | tr -d '\n\r ') + +common="networks: + - '$project' + logging: + driver: 'json-file' + options: + max-size: '100m'" + +#################### +## Start dependency stacks + +if [[ "$chain_providers" == "$default_providers" ]] +then + bash "$root/ops/start-chains.sh" + config=$( + echo "$config" '{"chainAddresses":'"$(cat "$root/.chaindata/chain-addresses.json")"'}' \ + | jq -s '.[0] + .[1]' + ) +fi + +if [[ -z "$messaging_url" ]] +then bash "$root/ops/start-messaging.sh" +fi + +echo +echo "Preparing to launch $stack stack" + +######################################## +## Node config + +internal_node_port="8000" +internal_prisma_port="5555" + +carol_node_port="8005" +carol_prisma="5555" +carol_mnemonic="owner warrior discover outer physical intact secret goose all photo napkin fall" +echo "$stack.carol will be exposed on *:$carol_node_port" + +dave_node_port="8006" +dave_prisma="5556" +dave_mnemonic="woman benefit lawn ignore glove marriage crumble roast tool area cool payment" +echo "$stack.dave will be exposed on *:$dave_node_port" + +roger_node_port="8007" +roger_prisma="5557" +roger_mnemonic="spice notable wealth rail voyage depth barely thumb skill rug panel blush" +echo "$stack.roger will be exposed on *:$roger_node_port" + +config=$(echo "$config" '{"nodeUrl":"http://roger:'$internal_node_port'"}' | jq -s '.[0] + .[1]') + +public_url="http://localhost:$roger_node_port" + +roger1_node_port="8017" +roger1_prisma="5567" +roger1_mnemonic="once must equal enable soon arrow spider gun era kitten unhappy invest" +echo "$stack.roger1 will be exposed on *:$roger1_node_port" + +config1=$(echo "$config1" '{"nodeUrl":"http://roger1:'$internal_node_port'"}' | jq -s '.[0] + .[1]') + +public_url1="http://localhost:$roger1_node_port" + +roger2_node_port="8027" +roger2_prisma="5577" +roger2_mnemonic="portion quote prison hope forget scout script axis fiscal crystal table chaos" +echo "$stack.roger2 will be exposed on *:$roger2_node_port" + +config2=$(echo "$config2" '{"nodeUrl":"http://roger2:'$internal_node_port'"}' | jq -s '.[0] + .[1]') + +public_url2="http://localhost:$roger2_node_port" + +node_image="image: '${project}_builder' + entrypoint: 'bash modules/server-node/ops/entry.sh' + volumes: + - '$root:/app' + tmpfs: /tmp" + +node_env="environment: + VECTOR_CONFIG: '$config'" + +######################################## +## Router config + +router_port="8000" +router_public_port="8009" +echo "$stack.router will be exposed on *:$router_public_port" + +router_image="image: '${project}_builder' + entrypoint: 'bash modules/router/ops/entry.sh' + volumes: + - '$root:/app' + ports: + - '$router_public_port:$router_port'" + + +router_port1="8001" +router_public_port1="8010" +echo "$stack.router1 will be exposed on *:$router_public_port1" + +router_image1="image: '${project}_builder' + entrypoint: 'bash modules/router/ops/entry.sh' + volumes: + - '$root:/app' + ports: + - '$router_public_port1:$router_port1'" + +router_port2="8002" +router_public_port2="8011" +echo "$stack.router2 will be exposed on *:$router_public_port2" + +router_image2="image: '${project}_builder' + entrypoint: 'bash modules/router/ops/entry.sh' + volumes: + - '$root:/app' + ports: + - '$router_public_port2:$router_port2'" + +#################### +# Observability tools config + +grafana_image="grafana/grafana:latest" +bash "$root/ops/pull-images.sh" "$grafana_image" > /dev/null + +prometheus_image="prom/prometheus:latest" +bash "$root/ops/pull-images.sh" "$prometheus_image" > /dev/null + +cadvisor_image="gcr.io/google-containers/cadvisor:latest" +bash "$root/ops/pull-images.sh" "$cadvisor_image" > /dev/null + +prometheus_services="prometheus: + image: $prometheus_image + $common + ports: + - 9090:9090 + command: + - --config.file=/etc/prometheus/prometheus.yml + volumes: + - $root/ops/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro + cadvisor: + $common + image: $cadvisor_image + ports: + - 8081:8080 + volumes: + - /:/rootfs:ro + - /var/run:/var/run:rw + - /sys:/sys:ro + - /var/lib/docker/:/var/lib/docker:ro" + +grafana_service="grafana: + image: '$grafana_image' + $common + networks: + - '$project' + ports: + - '3008:3000' + volumes: + - '$root/ops/grafana/grafana:/etc/grafana' + - '$root/ops/grafana/dashboards:/etc/dashboards'" + +observability_services="$prometheus_services + $grafana_service" + +#################### +# Launch stack + +docker_compose=$root/.${stack}.docker-compose.yml +rm -f "$docker_compose" +cat - > "$docker_compose" <