diff --git a/test/integration/channel-contracts.ts b/test/integration/channel-contracts.ts new file mode 100644 index 0000000000..af0ccc51ca --- /dev/null +++ b/test/integration/channel-contracts.ts @@ -0,0 +1,351 @@ +import { + describe, it, before, after, beforeEach, afterEach, +} from 'mocha'; +import { expect } from 'chai'; +import * as sinon from 'sinon'; +import { getSdk, networkId } from '.'; +import { + buildTxHash, encode, decode, Encoded, Encoding, Tag, AbiVersion, VmVersion, AeSdk, Contract, + Channel, buildTx, MemoryAccount, +} from '../../src'; +import { SignTxWithTag } from '../../src/channel/internal'; +import { assertNotNull } from '../utils'; +import { initializeChannels, recreateAccounts } from './channel-utils'; + +const contractSourceCode = ` +contract Identity = + entrypoint getArg(x : int) : int = x +`; + +describe('Channel contracts', () => { + let aeSdk: AeSdk; + let initiator: MemoryAccount; + let responder: MemoryAccount; + let initiatorCh: Channel; + let responderCh: Channel; + let responderShouldRejectUpdate: number | boolean; + let contractAddress: Encoded.ContractAddress; + let callerNonce: number; + let contract: Contract<{}>; + const initiatorSign = async (tx: Encoded.Transaction): Promise => ( + initiator.signTransaction(tx, { networkId }) + ); + const responderSign = async (tx: Encoded.Transaction): Promise => ( + responder.signTransaction(tx, { networkId }) + ); + const responderSignTag = sinon.spy(async (_tag, tx: Encoded.Transaction) => { + if (typeof responderShouldRejectUpdate === 'number') { + return responderShouldRejectUpdate as unknown as Encoded.Transaction; + } + if (responderShouldRejectUpdate) { + return null as unknown as Encoded.Transaction; + } + return responderSign(tx); + }); + const initiatorSignedTx = async (): Promise => { + const { signedTx } = await initiatorCh.state(); + assertNotNull(signedTx); + return buildTx(signedTx); + }; + const initiatorParams = { + role: 'initiator', + host: 'localhost', + sign: async (_tag: string, tx: Encoded.Transaction) => initiatorSign(tx), + } as const; + const responderParams = { + role: 'responder', + sign: responderSignTag, + } as const; + + before(async () => { + aeSdk = await getSdk(); + [initiator, responder] = await recreateAccounts(aeSdk); + }); + + after(() => { + initiatorCh.disconnect(); + responderCh.disconnect(); + }); + + beforeEach(() => { + responderShouldRejectUpdate = false; + }); + + afterEach(() => { + responderSignTag.resetHistory(); + }); + + it('can create a contract and accept', async () => { + [initiatorCh, responderCh] = await initializeChannels(initiatorParams, responderParams); + contract = await Contract.initialize({ ...aeSdk.getContext(), sourceCode: contractSourceCode }); + const initiatorNewContract = sinon.spy(); + initiatorCh.on('newContract', initiatorNewContract); + const responderNewContract = sinon.spy(); + responderCh.on('newContract', responderNewContract); + const roundBefore = initiatorCh.round(); + assertNotNull(roundBefore); + const callData = contract._calldata.encode('Identity', 'init', []); + const result = await initiatorCh.createContract({ + code: await contract.$compile(), + callData, + deposit: 1000, + vmVersion: VmVersion.Fate, + abiVersion: AbiVersion.Fate, + }, initiatorSign); + result.should.eql({ + accepted: true, address: result.address, signedTx: await initiatorSignedTx(), + }); + expect(initiatorCh.round()).to.equal(roundBefore + 1); + sinon.assert.calledTwice(responderSignTag); + sinon.assert.calledWithExactly( + responderSignTag, + 'update_ack', + sinon.match.string, + { + updates: [{ + abi_version: AbiVersion.Fate, + call_data: callData, + code: await contract.$compile(), + deposit: 1000, + op: 'OffChainNewContract', + owner: sinon.match.string, + vm_version: VmVersion.Fate, + }], + }, + ); + async function getContractAddresses(channel: Channel): Promise { + return Object.keys((await channel.state()).trees.contracts) as Encoded.ContractAddress[]; + } + expect(initiatorNewContract.callCount).to.equal(1); + expect(initiatorNewContract.firstCall.args).to.eql([result.address]); + expect(responderNewContract.callCount).to.equal(1); + expect(responderNewContract.firstCall.args).to.eql([result.address]); + expect(await getContractAddresses(initiatorCh)).to.eql([result.address]); + expect(await getContractAddresses(responderCh)).to.eql([result.address]); + contractAddress = result.address; + + await responderCh.createContract({ + code: await contract.$compile(), + callData: contract._calldata.encode('Identity', 'init', []), + deposit: 1e14, + vmVersion: VmVersion.Fate, + abiVersion: AbiVersion.Fate, + }, responderSign); + const contracts = await getContractAddresses(initiatorCh); + expect(contracts.length).to.equal(2); + expect(await getContractAddresses(responderCh)).to.eql(contracts); + const secondContract = contracts.filter((c) => c !== result.address); + expect(initiatorNewContract.callCount).to.equal(2); + expect(initiatorNewContract.secondCall.args).to.eql(secondContract); + expect(responderNewContract.callCount).to.equal(2); + expect(responderNewContract.secondCall.args).to.eql(secondContract); + }); + + it('can create a contract and reject', async () => { + responderShouldRejectUpdate = true; + const roundBefore = initiatorCh.round(); + const result = await initiatorCh.createContract({ + code: await contract.$compile(), + callData: contract._calldata.encode('Identity', 'init', []), + deposit: 1e14, + vmVersion: VmVersion.Fate, + abiVersion: AbiVersion.Fate, + }, initiatorSign); + expect(initiatorCh.round()).to.equal(roundBefore); + result.should.eql({ ...result, accepted: false }); + }); + + it('can abort contract sign request', async () => { + const errorCode = 12345; + const result = await initiatorCh.createContract( + { + code: await contract.$compile(), + callData: contract._calldata.encode('Identity', 'init', []), + deposit: 1e14, + vmVersion: VmVersion.Fate, + abiVersion: AbiVersion.Fate, + }, + async () => Promise.resolve(errorCode), + ); + result.should.eql({ accepted: false }); + }); + + it('can abort contract with custom error code', async () => { + responderShouldRejectUpdate = 12345; + const result = await initiatorCh.createContract({ + code: await contract.$compile(), + callData: contract._calldata.encode('Identity', 'init', []), + deposit: 1e14, + vmVersion: VmVersion.Fate, + abiVersion: AbiVersion.Fate, + }, initiatorSign); + result.should.eql({ + accepted: false, + errorCode: responderShouldRejectUpdate, + errorMessage: 'user-defined', + }); + }); + + it('can get balances', async () => { + const contractAddr = encode(decode(contractAddress), Encoding.AccountAddress); + const addresses = [initiator.address, responder.address, contractAddr]; + const balances = await initiatorCh.balances(addresses); + balances.should.be.an('object'); + // TODO: use the same type not depending on value after fixing https://github.com/aeternity/aepp-sdk-js/issues/1926 + balances[initiator.address].should.be.a('number'); + balances[responder.address].should.be.a('number'); + balances[contractAddr].should.be.equal(1000); + expect(balances).to.eql(await responderCh.balances(addresses)); + }); + + it('can call a contract and accept', async () => { + const roundBefore = initiatorCh.round(); + assertNotNull(roundBefore); + const result = await initiatorCh.callContract({ + amount: 0, + callData: contract._calldata.encode('Identity', 'getArg', [42]), + contract: contractAddress, + abiVersion: AbiVersion.Fate, + }, initiatorSign); + result.should.eql({ accepted: true, signedTx: await initiatorSignedTx() }); + const round = initiatorCh.round(); + assertNotNull(round); + expect(round).to.equal(roundBefore + 1); + callerNonce = round; + }); + + it('can call a force progress', async () => { + const forceTx = await initiatorCh.forceProgress({ + amount: 0, + callData: contract._calldata.encode('Identity', 'getArg', [42]), + contract: contractAddress, + abiVersion: AbiVersion.Fate, + }, initiatorSign); + const hash = buildTxHash(forceTx.tx); + const { callInfo } = await aeSdk.api.getTransactionInfoByHash(hash); + assertNotNull(callInfo); + expect(callInfo.returnType).to.be.equal('ok'); + }); + + it('can call a contract and reject', async () => { + responderShouldRejectUpdate = true; + const roundBefore = initiatorCh.round(); + const result = await initiatorCh.callContract({ + amount: 0, + callData: contract._calldata.encode('Identity', 'getArg', [42]), + contract: contractAddress, + abiVersion: AbiVersion.Fate, + }, initiatorSign); + expect(initiatorCh.round()).to.equal(roundBefore); + result.should.eql({ ...result, accepted: false }); + }); + + it('can abort contract call sign request', async () => { + const errorCode = 12345; + const result = await initiatorCh.callContract( + { + amount: 0, + callData: contract._calldata.encode('Identity', 'getArg', [42]), + contract: contractAddress, + abiVersion: AbiVersion.Fate, + }, + async () => Promise.resolve(errorCode), + ); + result.should.eql({ accepted: false }); + }); + + it('can abort contract call with custom error code', async () => { + responderShouldRejectUpdate = 12345; + const result = await initiatorCh.callContract({ + amount: 0, + callData: contract._calldata.encode('Identity', 'getArg', [42]), + contract: contractAddress, + abiVersion: AbiVersion.Fate, + }, initiatorSign); + result.should.eql({ + accepted: false, + errorCode: responderShouldRejectUpdate, + errorMessage: 'user-defined', + }); + }); + + it('can get contract call', async () => { + const result = await initiatorCh.getContractCall({ + caller: initiator.address, + contract: contractAddress, + round: callerNonce, + }); + result.should.eql({ + callerId: initiator.address, + callerNonce, + contractId: contractAddress, + gasPrice: result.gasPrice, + gasUsed: result.gasUsed, + height: result.height, + log: result.log, + returnType: 'ok', + returnValue: result.returnValue, + }); + expect(result.returnType).to.be.equal('ok'); + expect(contract._calldata.decode('Identity', 'getArg', result.returnValue).toString()).to.be.equal('42'); + }); + + it('can call a contract using dry-run', async () => { + const result = await initiatorCh.callContractStatic({ + amount: 0, + callData: contract._calldata.encode('Identity', 'getArg', [42]), + contract: contractAddress, + abiVersion: AbiVersion.Fate, + }); + result.should.eql({ + callerId: initiator.address, + callerNonce: result.callerNonce, + contractId: contractAddress, + gasPrice: result.gasPrice, + gasUsed: result.gasUsed, + height: result.height, + log: result.log, + returnType: 'ok', + returnValue: result.returnValue, + }); + expect(result.returnType).to.be.equal('ok'); + expect(contract._calldata.decode('Identity', 'getArg', result.returnValue).toString()).to.be.equal('42'); + }); + + it('can clean contract calls', async () => { + await initiatorCh.cleanContractCalls(); + await initiatorCh.getContractCall({ + caller: initiator.address, + contract: contractAddress, + round: callerNonce, + }).should.eventually.be.rejected; + }); + + it('can get contract state', async () => { + const result = await initiatorCh.getContractState(contractAddress); + result.should.eql({ + contract: { + abiVersion: AbiVersion.Fate, + active: true, + deposit: 1000, + id: contractAddress, + ownerId: initiator.address, + referrerIds: [], + vmVersion: VmVersion.Fate, + }, + contractState: result.contractState, + }); + // TODO: contractState deserialization + }); + + it.skip('can post snapshot solo transaction', async () => { + const snapshotSoloTx = await aeSdk.buildTx({ + tag: Tag.ChannelSnapshotSoloTx, + channelId: initiatorCh.id(), + fromId: initiator.address, + payload: await initiatorSignedTx(), + }); + // TODO: fix this, error: invalid_at_protocol + await aeSdk.sendTransaction(snapshotSoloTx, { onAccount: initiator }); + }); +}); diff --git a/test/integration/channel-other.ts b/test/integration/channel-other.ts new file mode 100644 index 0000000000..777b648766 --- /dev/null +++ b/test/integration/channel-other.ts @@ -0,0 +1,218 @@ +import { + describe, it, before, beforeEach, afterEach, +} from 'mocha'; +import { expect } from 'chai'; +import BigNumber from 'bignumber.js'; +import { getSdk, networkId, timeoutBlock } from '.'; +import { + unpackTx, Encoded, Tag, AeSdk, Channel, MemoryAccount, +} from '../../src'; +import { appendSignature } from '../../src/channel/handlers'; +import { assertNotNull } from '../utils'; +import { + waitForChannel, sharedParams, initializeChannels, recreateAccounts, +} from './channel-utils'; + +describe('Channel other', () => { + let aeSdk: AeSdk; + let initiator: MemoryAccount; + let responder: MemoryAccount; + let initiatorCh: Channel; + let responderCh: Channel; + const initiatorSign = async (tx: Encoded.Transaction): Promise => ( + initiator.signTransaction(tx, { networkId }) + ); + const responderSign = async (tx: Encoded.Transaction): Promise => ( + responder.signTransaction(tx, { networkId }) + ); + const initiatorParams = { + role: 'initiator', + host: 'localhost', + sign: async (_tag: string, tx: Encoded.Transaction) => initiatorSign(tx), + } as const; + const responderParams = { + role: 'responder', + sign: async (_tag: string, tx: Encoded.Transaction) => responderSign(tx), + } as const; + + async function getBalances(): Promise<[string, string]> { + const [bi, br] = await Promise.all( + [initiator.address, responder.address].map(async (a) => aeSdk.getBalance(a)), + ); + return [bi, br]; + } + + before(async () => { + aeSdk = await getSdk(3); + await Promise.all( + aeSdk.addresses().slice(1) + .map(async (onAccount) => aeSdk.transferFunds(1, aeSdk.address, { onAccount })), + ); + }); + + beforeEach(async () => { + [initiator, responder] = await recreateAccounts(aeSdk); + [initiatorCh, responderCh] = await initializeChannels(initiatorParams, responderParams); + }); + + afterEach(() => { + initiatorCh.disconnect(); + responderCh.disconnect(); + }); + + it('can solo close a channel', async () => { + const { signedTx } = await initiatorCh.update( + initiator.address, + responder.address, + 1e14, + initiatorSign, + ); + assertNotNull(signedTx); + const poi = await initiatorCh.poi({ + accounts: [initiator.address, responder.address], + }); + const balances = await initiatorCh.balances([initiator.address, responder.address]); + const [initiatorBalanceBeforeClose, responderBalanceBeforeClose] = await getBalances(); + const closeSoloTx = await aeSdk.buildTx({ + tag: Tag.ChannelCloseSoloTx, + channelId: await initiatorCh.id(), + fromId: initiator.address, + poi, + payload: signedTx, + }); + const closeSoloTxFee = unpackTx(closeSoloTx, Tag.ChannelCloseSoloTx).fee; + await aeSdk.sendTransaction(closeSoloTx, { onAccount: initiator }); + + const settleTx = await aeSdk.buildTx({ + tag: Tag.ChannelSettleTx, + channelId: await initiatorCh.id(), + fromId: initiator.address, + initiatorAmountFinal: balances[initiator.address], + responderAmountFinal: balances[responder.address], + }); + const settleTxFee = unpackTx(settleTx, Tag.ChannelSettleTx).fee; + await aeSdk.sendTransaction(settleTx, { onAccount: initiator }); + + const [initiatorBalanceAfterClose, responderBalanceAfterClose] = await getBalances(); + new BigNumber(initiatorBalanceAfterClose) + .minus(initiatorBalanceBeforeClose) + .plus(closeSoloTxFee) + .plus(settleTxFee) + .isEqualTo(balances[initiator.address]) + .should.be.equal(true); + new BigNumber(responderBalanceAfterClose) + .minus(responderBalanceBeforeClose) + .isEqualTo(balances[responder.address]) + .should.be.equal(true); + }).timeout(timeoutBlock); + + it('can dispute via slash tx', async () => { + const [initiatorBalanceBeforeClose, responderBalanceBeforeClose] = await getBalances(); + const oldUpdate = await initiatorCh + .update(initiator.address, responder.address, 100, initiatorSign); + const oldPoi = await initiatorCh.poi({ + accounts: [initiator.address, responder.address], + }); + const recentUpdate = await initiatorCh + .update(initiator.address, responder.address, 100, initiatorSign); + const recentPoi = await responderCh.poi({ + accounts: [initiator.address, responder.address], + }); + const recentBalances = await responderCh.balances([initiator.address, responder.address]); + assertNotNull(oldUpdate.signedTx); + const closeSoloTx = await aeSdk.buildTx({ + tag: Tag.ChannelCloseSoloTx, + channelId: initiatorCh.id(), + fromId: initiator.address, + poi: oldPoi, + payload: oldUpdate.signedTx, + }); + const closeSoloTxFee = unpackTx(closeSoloTx, Tag.ChannelCloseSoloTx).fee; + await aeSdk.sendTransaction(closeSoloTx, { onAccount: initiator }); + + assertNotNull(recentUpdate.signedTx); + const slashTx = await aeSdk.buildTx({ + tag: Tag.ChannelSlashTx, + channelId: responderCh.id(), + fromId: responder.address, + poi: recentPoi, + payload: recentUpdate.signedTx, + }); + const slashTxFee = unpackTx(slashTx, Tag.ChannelSlashTx).fee; + await aeSdk.sendTransaction(slashTx, { onAccount: responder }); + const settleTx = await aeSdk.buildTx({ + tag: Tag.ChannelSettleTx, + channelId: responderCh.id(), + fromId: responder.address, + initiatorAmountFinal: recentBalances[initiator.address], + responderAmountFinal: recentBalances[responder.address], + }); + const settleTxFee = unpackTx(settleTx, Tag.ChannelSettleTx).fee; + await aeSdk.sendTransaction(settleTx, { onAccount: responder }); + + const [initiatorBalanceAfterClose, responderBalanceAfterClose] = await getBalances(); + new BigNumber(initiatorBalanceAfterClose) + .minus(initiatorBalanceBeforeClose) + .plus(closeSoloTxFee) + .isEqualTo(recentBalances[initiator.address]) + .should.be.equal(true); + new BigNumber(responderBalanceAfterClose) + .minus(responderBalanceBeforeClose) + .plus(slashTxFee) + .plus(settleTxFee) + .isEqualTo(recentBalances[responder.address]) + .should.be.equal(true); + }).timeout(timeoutBlock); + + // https://github.com/aeternity/protocol/blob/d634e7a3f3110657900759b183d0734e61e5803a/node/api/channels_api_usage.md#reestablish + it('can reconnect', async () => { + expect(await initiatorCh.round()).to.be.equal(1); + const result = await initiatorCh.update( + initiator.address, + responder.address, + 100, + initiatorSign, + ); + expect(result.accepted).to.equal(true); + const channelId = await initiatorCh.id(); + const fsmId = initiatorCh.fsmId(); + initiatorCh.disconnect(); + const ch = await Channel.initialize({ + ...sharedParams, + ...initiatorParams, + existingChannelId: channelId, + existingFsmId: fsmId, + }); + await waitForChannel(ch); + expect(ch.fsmId()).to.be.equal(fsmId); + expect(await ch.round()).to.be.equal(2); + const state = await ch.state(); + ch.disconnect(); + assertNotNull(state.signedTx); + expect(state.signedTx.encodedTx.tag).to.be.equal(Tag.ChannelOffChainTx); + }); + + it('can post backchannel update', async () => { + expect(await responderCh.round()).to.be.equal(1); + initiatorCh.disconnect(); + const { accepted } = await responderCh.update( + initiator.address, + responder.address, + 100, + responderSign, + ); + expect(accepted).to.equal(false); + expect(await responderCh.round()).to.be.equal(1); + const result = await responderCh.update( + initiator.address, + responder.address, + 100, + async (transaction) => ( + appendSignature(await responderSign(transaction), initiatorSign) + ), + ); + result.accepted.should.equal(true); + expect(await responderCh.round()).to.be.equal(2); + expect(result.signedTx).to.be.a('string'); + }); +}); diff --git a/test/integration/channel-utils.ts b/test/integration/channel-utils.ts new file mode 100644 index 0000000000..ae64da29de --- /dev/null +++ b/test/integration/channel-utils.ts @@ -0,0 +1,61 @@ +import { channelUrl } from '.'; +import { + Encoded, Channel, MemoryAccount, AeSdk, +} from '../../src'; +import { ChannelOptions, SignTxWithTag } from '../../src/channel/internal'; + +export async function waitForChannel(channel: Channel): Promise { + return new Promise((resolve, reject) => { + channel.on('statusChanged', (status: string) => { + switch (status) { + case 'open': + resolve(); + break; + case 'disconnected': + reject(new Error('Unexpected SC status: disconnected')); + break; + default: + } + }); + }); +} + +export const sharedParams = { + url: channelUrl, + pushAmount: 1e13, + initiatorAmount: 5e14, + responderAmount: 5e14, + channelReserve: 0, + port: 3114, + lockPeriod: 1, + initiatorId: 'ak_' as Encoded.AccountAddress, + responderId: 'ak_' as Encoded.AccountAddress, + minimumDepth: 0, + minimumDepthStrategy: 'plain' as const, +}; + +export async function initializeChannels( + initiatorParams: { role: 'initiator'; host: string; sign: SignTxWithTag } & Partial, + responderParams: { role: 'responder'; sign: SignTxWithTag } & Partial, +): Promise<[Channel, Channel]> { + const initiatorCh = await Channel.initialize({ + ...sharedParams, + ...initiatorParams, + }); + const responderCh = await Channel.initialize({ + ...sharedParams, + ...responderParams, + }); + await Promise.all([waitForChannel(initiatorCh), waitForChannel(responderCh)]); + return [initiatorCh, responderCh]; +} + +export async function recreateAccounts(aeSdk: AeSdk): Promise<[MemoryAccount, MemoryAccount]> { + const initiator = MemoryAccount.generate(); + const responder = MemoryAccount.generate(); + await aeSdk.spend(3e15, initiator.address); + await aeSdk.spend(3e15, responder.address); + sharedParams.initiatorId = initiator.address; + sharedParams.responderId = responder.address; + return [initiator, responder]; +} diff --git a/test/integration/channel.ts b/test/integration/channel.ts index 85937fdf31..757d55f530 100644 --- a/test/integration/channel.ts +++ b/test/integration/channel.ts @@ -3,52 +3,18 @@ import { } from 'mocha'; import { expect } from 'chai'; import * as sinon from 'sinon'; -import BigNumber from 'bignumber.js'; +import { getSdk, networkId } from '.'; import { - getSdk, networkId, channelUrl, timeoutBlock, -} from '.'; -import { - unpackTx, - buildTxHash, - encode, decode, Encoded, Encoding, - Tag, - AbiVersion, - VmVersion, - IllegalArgumentError, - InsufficientBalanceError, - ChannelConnectionError, - ChannelIncomingMessageError, - UnknownChannelStateError, - AeSdk, - Contract, - Channel, - buildTx, - MemoryAccount, + unpackTx, Encoded, Tag, + IllegalArgumentError, InsufficientBalanceError, ChannelConnectionError, + ChannelIncomingMessageError, UnknownChannelStateError, + AeSdk, Channel, buildTx, MemoryAccount, } from '../../src'; import { notify, SignTx, SignTxWithTag } from '../../src/channel/internal'; -import { appendSignature } from '../../src/channel/handlers'; import { assertNotNull, ensureEqual, ensureInstanceOf } from '../utils'; - -const contractSourceCode = ` -contract Identity = - entrypoint getArg(x : int) : int = x -`; - -async function waitForChannel(channel: Channel): Promise { - return new Promise((resolve, reject) => { - channel.on('statusChanged', (status: string) => { - switch (status) { - case 'open': - resolve(); - break; - case 'disconnected': - reject(new Error('Unexpected SC status: disconnected')); - break; - default: - } - }); - }); -} +import { + waitForChannel, sharedParams, initializeChannels, recreateAccounts, +} from './channel-utils'; describe('Channel', () => { let aeSdk: AeSdk; @@ -57,9 +23,6 @@ describe('Channel', () => { let initiatorCh: Channel; let responderCh: Channel; let responderShouldRejectUpdate: number | boolean; - let contractAddress: Encoded.ContractAddress; - let callerNonce: number; - let contract: Contract<{}>; const initiatorSign = sinon.spy( // eslint-disable-next-line @typescript-eslint/no-unused-vars async (tx: Encoded.Transaction, o?: Parameters[1]): Promise => ( @@ -89,19 +52,6 @@ describe('Channel', () => { assertNotNull(signedTx); return buildTx(signedTx); }; - const sharedParams = { - url: channelUrl, - pushAmount: 1e13, - initiatorAmount: 5e14, - responderAmount: 5e14, - channelReserve: 0, - port: 3114, - lockPeriod: 1, - initiatorId: 'ak_' as Encoded.AccountAddress, - responderId: 'ak_' as Encoded.AccountAddress, - minimumDepth: 0, - minimumDepthStrategy: 'plain' as const, - }; const initiatorParams = { role: 'initiator', host: 'localhost', @@ -112,25 +62,9 @@ describe('Channel', () => { sign: responderSignTag, } as const; - async function recreateAccounts(): Promise { - initiator = MemoryAccount.generate(); - responder = MemoryAccount.generate(); - await aeSdk.spend(3e15, initiator.address); - await aeSdk.spend(3e15, responder.address); - sharedParams.initiatorId = initiator.address; - sharedParams.responderId = responder.address; - } - - async function getBalances(): Promise<[string, string]> { - const [bi, br] = await Promise.all( - [initiator.address, responder.address].map(async (a) => aeSdk.getBalance(a)), - ); - return [bi, br]; - } - before(async () => { aeSdk = await getSdk(); - await recreateAccounts(); + [initiator, responder] = await recreateAccounts(aeSdk); }); after(() => { @@ -150,17 +84,8 @@ describe('Channel', () => { }); it('can open a channel', async () => { - initiatorCh = await Channel.initialize({ - ...sharedParams, - ...initiatorParams, - }); - const initiatorChOpenPromise = waitForChannel(initiatorCh); - responderCh = await Channel.initialize({ - ...sharedParams, - ...responderParams, - }); - const responderChOpenPromise = waitForChannel(responderCh); - await Promise.all([initiatorChOpenPromise, responderChOpenPromise]); + [initiatorCh, responderCh] = await initializeChannels(initiatorParams, responderParams); + expect(initiatorCh.round()).to.equal(1); expect(responderCh.round()).to.equal(1); @@ -652,15 +577,7 @@ describe('Channel', () => { it('can leave a channel', async () => { initiatorCh.disconnect(); responderCh.disconnect(); - initiatorCh = await Channel.initialize({ - ...sharedParams, - ...initiatorParams, - }); - responderCh = await Channel.initialize({ - ...sharedParams, - ...responderParams, - }); - await Promise.all([waitForChannel(initiatorCh), waitForChannel(responderCh)]); + [initiatorCh, responderCh] = await initializeChannels(initiatorParams, responderParams); initiatorCh.round(); // existingChannelRound const result = await initiatorCh.leave(); expect(result.channelId).to.satisfy((t: string) => t.startsWith('ch_')); @@ -684,507 +601,11 @@ describe('Channel', () => { sinon.assert.notCalled(responderSignTag); }); - it('can solo close a channel', async () => { - initiatorCh.disconnect(); - responderCh.disconnect(); - await recreateAccounts(); - initiatorCh = await Channel.initialize({ - ...sharedParams, - ...initiatorParams, - }); - responderCh = await Channel.initialize({ - ...sharedParams, - ...responderParams, - }); - await Promise.all([waitForChannel(initiatorCh), waitForChannel(responderCh)]); - - const { signedTx } = await initiatorCh.update( - initiator.address, - responder.address, - 1e14, - initiatorSign, - ); - assertNotNull(signedTx); - const poi = await initiatorCh.poi({ - accounts: [initiator.address, responder.address], - }); - const balances = await initiatorCh.balances([initiator.address, responder.address]); - const [initiatorBalanceBeforeClose, responderBalanceBeforeClose] = await getBalances(); - const closeSoloTx = await aeSdk.buildTx({ - tag: Tag.ChannelCloseSoloTx, - channelId: await initiatorCh.id(), - fromId: initiator.address, - poi, - payload: signedTx, - }); - const closeSoloTxFee = unpackTx(closeSoloTx, Tag.ChannelCloseSoloTx).fee; - await aeSdk.sendTransaction(closeSoloTx, { onAccount: initiator }); - - const settleTx = await aeSdk.buildTx({ - tag: Tag.ChannelSettleTx, - channelId: await initiatorCh.id(), - fromId: initiator.address, - initiatorAmountFinal: balances[initiator.address], - responderAmountFinal: balances[responder.address], - }); - const settleTxFee = unpackTx(settleTx, Tag.ChannelSettleTx).fee; - await aeSdk.sendTransaction(settleTx, { onAccount: initiator }); - - const [initiatorBalanceAfterClose, responderBalanceAfterClose] = await getBalances(); - new BigNumber(initiatorBalanceAfterClose) - .minus(initiatorBalanceBeforeClose) - .plus(closeSoloTxFee) - .plus(settleTxFee) - .isEqualTo(balances[initiator.address]) - .should.be.equal(true); - new BigNumber(responderBalanceAfterClose) - .minus(responderBalanceBeforeClose) - .isEqualTo(balances[responder.address]) - .should.be.equal(true); - }).timeout(timeoutBlock); - - it('can dispute via slash tx', async () => { - initiatorCh.disconnect(); - responderCh.disconnect(); - initiatorCh = await Channel.initialize({ - ...sharedParams, - ...initiatorParams, - lockPeriod: 2, - }); - responderCh = await Channel.initialize({ - ...sharedParams, - ...responderParams, - lockPeriod: 2, - }); - await Promise.all([waitForChannel(initiatorCh), waitForChannel(responderCh)]); - const [initiatorBalanceBeforeClose, responderBalanceBeforeClose] = await getBalances(); - const oldUpdate = await initiatorCh - .update(initiator.address, responder.address, 100, initiatorSign); - const oldPoi = await initiatorCh.poi({ - accounts: [initiator.address, responder.address], - }); - const recentUpdate = await initiatorCh - .update(initiator.address, responder.address, 100, initiatorSign); - const recentPoi = await responderCh.poi({ - accounts: [initiator.address, responder.address], - }); - const recentBalances = await responderCh.balances([initiator.address, responder.address]); - assertNotNull(oldUpdate.signedTx); - const closeSoloTx = await aeSdk.buildTx({ - tag: Tag.ChannelCloseSoloTx, - channelId: initiatorCh.id(), - fromId: initiator.address, - poi: oldPoi, - payload: oldUpdate.signedTx, - }); - const closeSoloTxFee = unpackTx(closeSoloTx, Tag.ChannelCloseSoloTx).fee; - await aeSdk.sendTransaction(closeSoloTx, { onAccount: initiator }); - - assertNotNull(recentUpdate.signedTx); - const slashTx = await aeSdk.buildTx({ - tag: Tag.ChannelSlashTx, - channelId: responderCh.id(), - fromId: responder.address, - poi: recentPoi, - payload: recentUpdate.signedTx, - }); - const slashTxFee = unpackTx(slashTx, Tag.ChannelSlashTx).fee; - await aeSdk.sendTransaction(slashTx, { onAccount: responder }); - const settleTx = await aeSdk.buildTx({ - tag: Tag.ChannelSettleTx, - channelId: responderCh.id(), - fromId: responder.address, - initiatorAmountFinal: recentBalances[initiator.address], - responderAmountFinal: recentBalances[responder.address], - }); - const settleTxFee = unpackTx(settleTx, Tag.ChannelSettleTx).fee; - await aeSdk.sendTransaction(settleTx, { onAccount: responder }); - - const [initiatorBalanceAfterClose, responderBalanceAfterClose] = await getBalances(); - new BigNumber(initiatorBalanceAfterClose) - .minus(initiatorBalanceBeforeClose) - .plus(closeSoloTxFee) - .isEqualTo(recentBalances[initiator.address]) - .should.be.equal(true); - new BigNumber(responderBalanceAfterClose) - .minus(responderBalanceBeforeClose) - .plus(slashTxFee) - .plus(settleTxFee) - .isEqualTo(recentBalances[responder.address]) - .should.be.equal(true); - }).timeout(timeoutBlock); - - it('can create a contract and accept', async () => { - initiatorCh.disconnect(); - responderCh.disconnect(); - initiatorCh = await Channel.initialize({ - ...sharedParams, - ...initiatorParams, - }); - responderCh = await Channel.initialize({ - ...sharedParams, - ...responderParams, - }); - await Promise.all([waitForChannel(initiatorCh), waitForChannel(responderCh)]); - contract = await Contract.initialize({ ...aeSdk.getContext(), sourceCode: contractSourceCode }); - const initiatorNewContract = sinon.spy(); - initiatorCh.on('newContract', initiatorNewContract); - const responderNewContract = sinon.spy(); - responderCh.on('newContract', responderNewContract); - const roundBefore = initiatorCh.round(); - assertNotNull(roundBefore); - const callData = contract._calldata.encode('Identity', 'init', []); - const result = await initiatorCh.createContract({ - code: await contract.$compile(), - callData, - deposit: 1000, - vmVersion: VmVersion.Fate, - abiVersion: AbiVersion.Fate, - }, initiatorSign); - result.should.eql({ - accepted: true, address: result.address, signedTx: await initiatorSignedTx(), - }); - expect(initiatorCh.round()).to.equal(roundBefore + 1); - sinon.assert.calledTwice(responderSignTag); - sinon.assert.calledWithExactly( - responderSignTag, - 'update_ack', - sinon.match.string, - { - updates: [{ - abi_version: AbiVersion.Fate, - call_data: callData, - code: await contract.$compile(), - deposit: 1000, - op: 'OffChainNewContract', - owner: sinon.match.string, - vm_version: VmVersion.Fate, - }], - }, - ); - async function getContractAddresses(channel: Channel): Promise { - return Object.keys((await channel.state()).trees.contracts) as Encoded.ContractAddress[]; - } - expect(initiatorNewContract.callCount).to.equal(1); - expect(initiatorNewContract.firstCall.args).to.eql([result.address]); - expect(responderNewContract.callCount).to.equal(1); - expect(responderNewContract.firstCall.args).to.eql([result.address]); - expect(await getContractAddresses(initiatorCh)).to.eql([result.address]); - expect(await getContractAddresses(responderCh)).to.eql([result.address]); - contractAddress = result.address; - - await responderCh.createContract({ - code: await contract.$compile(), - callData: contract._calldata.encode('Identity', 'init', []), - deposit: 1e14, - vmVersion: VmVersion.Fate, - abiVersion: AbiVersion.Fate, - }, responderSign); - const contracts = await getContractAddresses(initiatorCh); - expect(contracts.length).to.equal(2); - expect(await getContractAddresses(responderCh)).to.eql(contracts); - const secondContract = contracts.filter((c) => c !== result.address); - expect(initiatorNewContract.callCount).to.equal(2); - expect(initiatorNewContract.secondCall.args).to.eql(secondContract); - expect(responderNewContract.callCount).to.equal(2); - expect(responderNewContract.secondCall.args).to.eql(secondContract); - }); - - it('can create a contract and reject', async () => { - responderShouldRejectUpdate = true; - const roundBefore = initiatorCh.round(); - const result = await initiatorCh.createContract({ - code: await contract.$compile(), - callData: contract._calldata.encode('Identity', 'init', []), - deposit: 1e14, - vmVersion: VmVersion.Fate, - abiVersion: AbiVersion.Fate, - }, initiatorSign); - expect(initiatorCh.round()).to.equal(roundBefore); - result.should.eql({ ...result, accepted: false }); - }); - - it('can abort contract sign request', async () => { - const errorCode = 12345; - const result = await initiatorCh.createContract( - { - code: await contract.$compile(), - callData: contract._calldata.encode('Identity', 'init', []), - deposit: 1e14, - vmVersion: VmVersion.Fate, - abiVersion: AbiVersion.Fate, - }, - async () => Promise.resolve(errorCode), - ); - result.should.eql({ accepted: false }); - }); - - it('can abort contract with custom error code', async () => { - responderShouldRejectUpdate = 12345; - const result = await initiatorCh.createContract({ - code: await contract.$compile(), - callData: contract._calldata.encode('Identity', 'init', []), - deposit: 1e14, - vmVersion: VmVersion.Fate, - abiVersion: AbiVersion.Fate, - }, initiatorSign); - result.should.eql({ - accepted: false, - errorCode: responderShouldRejectUpdate, - errorMessage: 'user-defined', - }); - }); - - it('can get balances', async () => { - const contractAddr = encode(decode(contractAddress), Encoding.AccountAddress); - const addresses = [initiator.address, responder.address, contractAddr]; - const balances = await initiatorCh.balances(addresses); - balances.should.be.an('object'); - // TODO: use the same type not depending on value after fixing https://github.com/aeternity/aepp-sdk-js/issues/1926 - balances[initiator.address].should.be.a('number'); - balances[responder.address].should.be.a('number'); - balances[contractAddr].should.be.equal(1000); - expect(balances).to.eql(await responderCh.balances(addresses)); - }); - - it('can call a contract and accept', async () => { - const roundBefore = initiatorCh.round(); - assertNotNull(roundBefore); - const result = await initiatorCh.callContract({ - amount: 0, - callData: contract._calldata.encode('Identity', 'getArg', [42]), - contract: contractAddress, - abiVersion: AbiVersion.Fate, - }, initiatorSign); - result.should.eql({ accepted: true, signedTx: await initiatorSignedTx() }); - const round = initiatorCh.round(); - assertNotNull(round); - expect(round).to.equal(roundBefore + 1); - callerNonce = round; - }); - - it('can call a force progress', async () => { - const forceTx = await initiatorCh.forceProgress({ - amount: 0, - callData: contract._calldata.encode('Identity', 'getArg', [42]), - contract: contractAddress, - abiVersion: AbiVersion.Fate, - }, initiatorSign); - const hash = buildTxHash(forceTx.tx); - const { callInfo } = await aeSdk.api.getTransactionInfoByHash(hash); - assertNotNull(callInfo); - expect(callInfo.returnType).to.be.equal('ok'); - }); - - it('can call a contract and reject', async () => { - responderShouldRejectUpdate = true; - const roundBefore = initiatorCh.round(); - const result = await initiatorCh.callContract({ - amount: 0, - callData: contract._calldata.encode('Identity', 'getArg', [42]), - contract: contractAddress, - abiVersion: AbiVersion.Fate, - }, initiatorSign); - expect(initiatorCh.round()).to.equal(roundBefore); - result.should.eql({ ...result, accepted: false }); - }); - - it('can abort contract call sign request', async () => { - const errorCode = 12345; - const result = await initiatorCh.callContract( - { - amount: 0, - callData: contract._calldata.encode('Identity', 'getArg', [42]), - contract: contractAddress, - abiVersion: AbiVersion.Fate, - }, - async () => Promise.resolve(errorCode), - ); - result.should.eql({ accepted: false }); - }); - - it('can abort contract call with custom error code', async () => { - responderShouldRejectUpdate = 12345; - const result = await initiatorCh.callContract({ - amount: 0, - callData: contract._calldata.encode('Identity', 'getArg', [42]), - contract: contractAddress, - abiVersion: AbiVersion.Fate, - }, initiatorSign); - result.should.eql({ - accepted: false, - errorCode: responderShouldRejectUpdate, - errorMessage: 'user-defined', - }); - }); - - it('can get contract call', async () => { - const result = await initiatorCh.getContractCall({ - caller: initiator.address, - contract: contractAddress, - round: callerNonce, - }); - result.should.eql({ - callerId: initiator.address, - callerNonce, - contractId: contractAddress, - gasPrice: result.gasPrice, - gasUsed: result.gasUsed, - height: result.height, - log: result.log, - returnType: 'ok', - returnValue: result.returnValue, - }); - expect(result.returnType).to.be.equal('ok'); - expect(contract._calldata.decode('Identity', 'getArg', result.returnValue).toString()).to.be.equal('42'); - }); - - it('can call a contract using dry-run', async () => { - const result = await initiatorCh.callContractStatic({ - amount: 0, - callData: contract._calldata.encode('Identity', 'getArg', [42]), - contract: contractAddress, - abiVersion: AbiVersion.Fate, - }); - result.should.eql({ - callerId: initiator.address, - callerNonce: result.callerNonce, - contractId: contractAddress, - gasPrice: result.gasPrice, - gasUsed: result.gasUsed, - height: result.height, - log: result.log, - returnType: 'ok', - returnValue: result.returnValue, - }); - expect(result.returnType).to.be.equal('ok'); - expect(contract._calldata.decode('Identity', 'getArg', result.returnValue).toString()).to.be.equal('42'); - }); - - it('can clean contract calls', async () => { - await initiatorCh.cleanContractCalls(); - await initiatorCh.getContractCall({ - caller: initiator.address, - contract: contractAddress, - round: callerNonce, - }).should.eventually.be.rejected; - }); - - it('can get contract state', async () => { - const result = await initiatorCh.getContractState(contractAddress); - result.should.eql({ - contract: { - abiVersion: AbiVersion.Fate, - active: true, - deposit: 1000, - id: contractAddress, - ownerId: initiator.address, - referrerIds: [], - vmVersion: VmVersion.Fate, - }, - contractState: result.contractState, - }); - // TODO: contractState deserialization - }); - - it.skip('can post snapshot solo transaction', async () => { - const snapshotSoloTx = await aeSdk.buildTx({ - tag: Tag.ChannelSnapshotSoloTx, - channelId: initiatorCh.id(), - fromId: initiator.address, - payload: await initiatorSignedTx(), - }); - // TODO: fix this, error: invalid_at_protocol - await aeSdk.sendTransaction(snapshotSoloTx, { onAccount: initiator }); - }); - - // https://github.com/aeternity/protocol/blob/d634e7a3f3110657900759b183d0734e61e5803a/node/api/channels_api_usage.md#reestablish - it('can reconnect', async () => { - initiatorCh.disconnect(); - responderCh.disconnect(); - initiatorCh = await Channel.initialize({ - ...sharedParams, - ...initiatorParams, - }); - responderCh = await Channel.initialize({ - ...sharedParams, - ...responderParams, - }); - await Promise.all([waitForChannel(initiatorCh), waitForChannel(responderCh)]); - expect(await initiatorCh.round()).to.be.equal(1); - const result = await initiatorCh.update( - initiator.address, - responder.address, - 100, - initiatorSign, - ); - expect(result.accepted).to.equal(true); - const channelId = await initiatorCh.id(); - const fsmId = initiatorCh.fsmId(); - initiatorCh.disconnect(); - const ch = await Channel.initialize({ - ...sharedParams, - ...initiatorParams, - existingChannelId: channelId, - existingFsmId: fsmId, - }); - await waitForChannel(ch); - expect(ch.fsmId()).to.be.equal(fsmId); - expect(await ch.round()).to.be.equal(2); - const state = await ch.state(); - ch.disconnect(); - assertNotNull(state.signedTx); - expect(state.signedTx.encodedTx.tag).to.be.equal(Tag.ChannelOffChainTx); - }); - - it('can post backchannel update', async () => { - initiatorCh.disconnect(); - responderCh.disconnect(); - initiatorCh = await Channel.initialize({ - ...sharedParams, - ...initiatorParams, - }); - responderCh = await Channel.initialize({ - ...sharedParams, - ...responderParams, - }); - await Promise.all([waitForChannel(initiatorCh), waitForChannel(responderCh)]); - expect(await responderCh.round()).to.be.equal(1); - initiatorCh.disconnect(); - const { accepted } = await responderCh.update( - initiator.address, - responder.address, - 100, - responderSign, - ); - expect(accepted).to.equal(false); - expect(await responderCh.round()).to.be.equal(1); - const result = await responderCh.update( - initiator.address, - responder.address, - 100, - async (transaction) => ( - appendSignature(await responderSign(transaction), initiatorSign) - ), - ); - result.accepted.should.equal(true); - expect(await responderCh.round()).to.be.equal(2); - expect(result.signedTx).to.be.a('string'); - }); - describe('throws errors', () => { before(async () => { initiatorCh.disconnect(); responderCh.disconnect(); - initiatorCh = await Channel.initialize({ - ...sharedParams, - ...initiatorParams, - }); - responderCh = await Channel.initialize({ - ...sharedParams, - ...responderParams, - }); - await Promise.all([waitForChannel(initiatorCh), waitForChannel(responderCh)]); + [initiatorCh, responderCh] = await initializeChannels(initiatorParams, responderParams); }); after(() => {