diff --git a/Makefile b/Makefile index b05d228b4..afde32add 100644 --- a/Makefile +++ b/Makefile @@ -38,7 +38,7 @@ dev: messaging node router duet trio 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 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..73f42531c 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-dev.0", + "@connext/vector-utils": "0.3.0-dev.0", "@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..7b81fef88 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-dev.0", "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-dev.0", + "@connext/vector-engine": "0.3.0-dev.0", + "@connext/vector-types": "0.3.0-dev.0", + "@connext/vector-utils": "0.3.0-dev.0", "@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..830a5aa53 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, 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..504dcb6c2 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-dev.0", "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-dev.0", + "@connext/vector-utils": "0.3.0-dev.0", "@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/ethService.ts b/modules/contracts/src.ts/services/ethService.ts index 4c7ad84c9..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"; @@ -1365,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, @@ -1373,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/engine/package.json b/modules/engine/package.json index 865243cc2..ffa5dd869 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-dev.0", "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-dev.0", + "@connext/vector-protocol": "0.3.0-dev.0", + "@connext/vector-types": "0.3.0-dev.0", + "@connext/vector-utils": "0.3.0-dev.0", "@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..43525bdf3 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"; diff --git a/modules/engine/src/index.ts b/modules/engine/src/index.ts index 1451fa723..d061b1417 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,21 @@ import { ChannelRpcMethods, IExternalValidation, AUTODEPLOY_CHAIN_IDS, - FullChannelState, EngineError, - UpdateType, - Values, VectorError, jsonifyError, MinimalTransaction, WITHDRAWAL_RESOLVED_EVENT, VectorErrorJson, } 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 { DisputeError, IsAliveError, RestoreError, RpcError } from "./errors"; +import { DisputeError, IsAliveError, RpcError } from "./errors"; import { convertConditionalTransferParams, convertResolveConditionParams, @@ -54,7 +43,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 +52,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 +59,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 +75,6 @@ export class VectorEngine implements IVectorEngine { ): Promise { const vector = await Vector.connect( messaging, - lock, store, signer, chainService, @@ -106,7 +89,6 @@ export class VectorEngine implements IVectorEngine { vector, chainService, chainAddresses, - lock, logger.child({ module: "VectorEngine" }), ); await engine.setupListener(gasSubsidyPercentage); @@ -139,59 +121,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 +645,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 +667,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 +1010,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 +1169,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 +1191,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()!); - } - - 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, - }); + // Request protocol restore + const restoreResult = await this.vector.restoreState(params); + if (restoreResult.isError) { + return Result.fail(restoreResult.getError()!); } - // 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 +1471,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(); 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..646f2670d 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, @@ -809,7 +804,6 @@ describe("VectorEngine", () => { 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/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..998762ba0 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-dev.0", + "@connext/vector-types": "0.3.0-dev.0", + "@connext/vector-utils": "0.3.0-dev.0", "@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..51496f57b 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-dev.0", "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-dev.0", + "@connext/vector-types": "0.3.0-dev.0", + "@connext/vector-utils": "0.3.0-dev.0", "@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..eac1152eb 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-dev.0", + "@connext/vector-engine": "0.3.0-dev.0", + "@connext/vector-types": "0.3.0-dev.0", + "@connext/vector-utils": "0.3.0-dev.0", "@ethersproject/abi": "5.2.0", "@ethersproject/address": "5.2.0", "@ethersproject/bignumber": "5.2.0", 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..54e5bebd0 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-dev.0", + "@connext/vector-engine": "0.3.0-dev.0", + "@connext/vector-types": "0.3.0-dev.0", + "@connext/vector-utils": "0.3.0-dev.0", "@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..4c98d07fe 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"; 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..89c20d3e8 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-dev.0", + "@connext/vector-types": "0.3.0-dev.0", + "@connext/vector-utils": "0.3.0-dev.0", "@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/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/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..6ac3e9585 100644 --- a/modules/test-runner/src/trio/eventSetup.ts +++ b/modules/test-runner/src/trio/eventSetup.ts @@ -19,7 +19,7 @@ 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"; 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..3f2478b5a 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-dev.0", + "@connext/vector-types": "0.3.0-dev.0", + "@connext/vector-utils": "0.3.0-dev.0", "@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..97b6171ad 100644 --- a/modules/test-ui/src/App.tsx +++ b/modules/test-ui/src/App.tsx @@ -1,23 +1,14 @@ -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 React, { useState, 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 { EngineEvents, FullChannelState, INodeService, jsonifyError, TransferNames } from "@connext/vector-types"; import "./App.css"; import { config } from "./config"; function App() { - const [node, setNode] = useState(); + const [node, setNode] = useState(); const [routerPublicIdentifier, setRouterPublicIdentifier] = useState(); const [channels, setChannels] = useState([]); const [selectedChannel, setSelectedChannel] = useState(); @@ -33,18 +24,35 @@ function App() { const [connectError, setConnectError] = useState(); const [copied, setCopied] = useState(false); - const [activeTab, setActiveTab] = useState<"HashlockTransfer" | "CrossChainTransfer">("HashlockTransfer"); + const [activeTab, setActiveTab] = useState<"HashlockTransfer" | "CrossChainTransfer" | "MultiTransfer">( + "HashlockTransfer", + ); const [withdrawForm] = Form.useForm(); const [transferForm] = Form.useForm(); + const [multiTransferForm] = Form.useForm(); const [signMessageForm] = Form.useForm(); + const [browserNodePkg, setBrowserNodePkg] = useState(); + const [utilsPkg, setUtilsPkg] = useState(); + + const loadWasmLibs = async () => { + const browser = await import("@connext/vector-browser-node"); + setBrowserNodePkg(browser); + const utils = await import("@connext/vector-utils"); + setUtilsPkg(utils); + }; + + useEffect(() => { + loadWasmLibs(); + }, []); + const connectNode = async ( iframeSrc: string, supportedChains: number[], _routerPublicIdentifier: string, loginProvider: "none" | "metamask" | "magic", - ): Promise => { + ): Promise => { try { setConnectLoading(true); setRouterPublicIdentifier(_routerPublicIdentifier); @@ -52,7 +60,7 @@ function App() { supportedChains.forEach((chain) => { chainProviders[chain] = config.chainProviders[chain]; }); - const client = new BrowserNode({ + const client = new browserNodePkg.BrowserNode({ supportedChains, iframeSrc, routerPublicIdentifier: _routerPublicIdentifier, @@ -110,8 +118,8 @@ function App() { return; } const channelAddresses = channelsRes.getValue(); - const _channels = ( - await Promise.all( + const _channels: FullChannelState[] = ( + await Promise.all( channelAddresses.map(async (c) => { const channelRes = await client.getStateChannel({ channelAddress: c }); console.log("Channel found in store:", channelRes.getValue()); @@ -119,7 +127,7 @@ function App() { return channelVal; }), ) - ).filter((chan) => supportedChains.includes(chan.networkContext.chainId)); + ).filter((chan: FullChannelState) => supportedChains.includes(chan.networkContext.chainId)); if (_channels.length > 0) { setChannels(_channels); setSelectedChannel(_channels[0]); @@ -140,7 +148,7 @@ function App() { console.log("No encrypted preImage attached", data.transfer); return; } - const rpc = constructRpcRequest<"chan_decrypt">("chan_decrypt", data.transfer.meta.encryptedPreImage); + const rpc = utilsPkg.constructRpcRequest("chan_decrypt", data.transfer.meta.encryptedPreImage); const decryptedPreImage = await client.send(rpc); const requestRes = await client.resolveTransfer({ @@ -177,7 +185,7 @@ function App() { chainProviders[chainId.toString()] = config.chainProviders[chainId.toString()]; }); console.error("creating new browser node on", supportedChains, "with providers", chainProviders); - const client = new BrowserNode({ + const client = new browserNodePkg.BrowserNode({ supportedChains, iframeSrc, routerPublicIdentifier: _routerPublicIdentifier, @@ -191,7 +199,7 @@ function App() { setConnectLoading(false); }; - const updateChannel = async (node: BrowserNode, channelAddress: string) => { + const updateChannel = async (node: INodeService, channelAddress: string) => { const res = await node.getStateChannel({ channelAddress }); if (res.isError) { console.error("Error getting state channel", res.getError()); @@ -243,13 +251,77 @@ function App() { setRequestCollateralLoading(false); }; + const multiTransfer = async (numberOfTransfers: number) => { + const recipientChannel = channels.find((c) => c.channelAddress !== selectedChannel.channelAddress); + if (!recipientChannel) { + console.error("No recipient channel"); + return; + } + + if ( + recipientChannel.networkContext.chainId === selectedChannel.networkContext.chainId && + recipientChannel.bobIdentifier === selectedChannel.bobIdentifier + ) { + console.error("Will not properly route"); + return; + } + + let recievedTransfers = 0; + node.on(EngineEvents.CONDITIONAL_TRANSFER_CREATED, (data) => { + if (data.channelAddress === recipientChannel.channelAddress) { + recievedTransfers++; + } + }); + + let requests = 0; + const completed = new Promise(async (resolve) => { + while (recievedTransfers < numberOfTransfers) { + if (requests !== numberOfTransfers) { + console.error(`seen ${requests}/${numberOfTransfers}, waiting 35s`); + await utilsPkg.delay(35_000); + continue; + } else { + console.error(`recipient has ${recievedTransfers} / ${numberOfTransfers}`); + await utilsPkg.delay(1_000); + } + } + resolve(undefined); + }); + + console.error(`Beginning transfers`); + for (let i = 0; i < numberOfTransfers; i++) { + (requests + 1) % 10 === 0 && console.error(`request ${requests + 1} / ${numberOfTransfers}`); + const preImage = utilsPkg.getRandomBytes32(); + const params = { + publicIdentifier: selectedChannel.bobIdentifier, + amount: "1", + assetId: constants.AddressZero, + channelAddress: selectedChannel.channelAddress, + type: TransferNames.HashlockTransfer, + details: { + lockHash: utilsPkg.createlockHash(preImage), + expiry: "0", + }, + recipient: recipientChannel.bobIdentifier, + recipientChainId: recipientChannel.networkContext.chainId, + }; + const create = await node.conditionalTransfer(params); + if (create.isError) { + throw create.getError(); + } + requests++; + } + await completed; + console.error("transfers completed"); + }; + 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); + const recipientPublicKey = utilsPkg.getPublicKeyFromPublicIdentifier(recipient); + const encryptedPreImage = await utilsPkg.encrypt(preImage, recipientPublicKey); submittedMeta.encryptedPreImage = encryptedPreImage; } @@ -260,7 +332,7 @@ function App() { amount, recipient, details: { - lockHash: createlockHash(preImage), + lockHash: utilsPkg.createlockHash(preImage), expiry: "0", }, meta: submittedMeta, @@ -616,7 +688,7 @@ function App() { name="transfer" initialValues={{ assetId: selectedChannel?.assetIds && selectedChannel?.assetIds[0], - preImage: getRandomBytes32(), + preImage: utilsPkg.getRandomBytes32(), numLoops: 1, }} onFinish={(values) => transfer(values.assetId, values.amount, values.recipient, values.preImage)} @@ -655,7 +727,7 @@ function App() { enterButton="MAX" onSearch={() => { const assetId = transferForm.getFieldValue("assetId"); - const amount = getBalanceForAssetId(selectedChannel, assetId, "bob"); + const amount = utilsPkg.getBalanceForAssetId(selectedChannel, assetId, "bob"); transferForm.setFieldsValue({ amount }); }} /> @@ -669,7 +741,7 @@ function App() { { - const preImage = getRandomBytes32(); + const preImage = utilsPkg.getRandomBytes32(); transferForm.setFieldsValue({ preImage }); }} /> @@ -738,6 +810,31 @@ function App() { + + +
multiTransfer(values.numOfTransfers)} + onFinishFailed={onFinishFailed} + form={multiTransferForm} + > + + + + + + + +
+
@@ -788,7 +885,7 @@ function App() { enterButton="MAX" onSearch={() => { const assetId = withdrawForm.getFieldValue("assetId"); - const amount = getBalanceForAssetId(selectedChannel, assetId, "bob"); + const amount = utilsPkg.getBalanceForAssetId(selectedChannel, assetId, "bob"); withdrawForm.setFieldsValue({ amount }); }} /> @@ -802,7 +899,7 @@ function App() { - Withdraw + Sign Message
= { +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/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..506f9a69f 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, 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..556b222e1 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 @@ -228,11 +229,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 +300,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; 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/version.ts b/modules/types/src/version.ts new file mode 100644 index 000000000..add59a974 --- /dev/null +++ b/modules/types/src/version.ts @@ -0,0 +1 @@ +export const PROTOCOL_VERSION = "0.3.0-dev.0"; diff --git a/modules/utils/package.json b/modules/utils/package.json index 5f1a54dc2..f28770008 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-dev.0", "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-dev.0", "@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/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..e688f5a43 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, @@ -335,13 +334,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 +354,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 +367,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 +383,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 +420,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 +502,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>, 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..d4ef75d94 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, 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