diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..21e9f02 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,13 @@ +name: CI +on: push +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: Install modules + run: yarn + - name: Run unit tests + run: yarn test:unit + - name: Run integration tests + run: yarn test:integration diff --git a/jest.config.js b/jest.config.js index 1a9c171..bcf1dda 100644 --- a/jest.config.js +++ b/jest.config.js @@ -18,6 +18,7 @@ module.exports = { testPathIgnorePatterns: [ "/.yalc/", + "/data/" ], testEnvironment: 'node', diff --git a/package.json b/package.json index 53106fa..1e1c275 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,10 @@ "version": "yarn format && git add -A src", "postversion": "git push && git push --tags", "yalc:publish": "yarn build && yalc publish --push", - "test": "jest" + "test": "jest", + "test:unit": "jest ./src/__tests__/unit", + "test:integration": "jest ./src/__tests__/integration", + "test:regression": "jest ./src/__tests__/regression" }, "license": "MIT", "author": "Redstone Team ", diff --git a/src/__tests__/integration/data/token-evolve.js b/src/__tests__/integration/data/token-evolve.js new file mode 100644 index 0000000..fdf04d5 --- /dev/null +++ b/src/__tests__/integration/data/token-evolve.js @@ -0,0 +1,66 @@ +export function handle(state, action) { + const balances = state.balances; + const canEvolve = state.canEvolve; + const input = action.input; + const caller = action.caller; + + if (input.function === 'transfer') { + const target = input.target; + const qty = input.qty; + + if (!Number.isInteger(qty)) { + throw new ContractError('Invalid value for "qty". Must be an integer'); + } + + if (!target) { + throw new ContractError('No target specified'); + } + + if (qty <= 0 || caller === target) { + throw new ContractError('Invalid token transfer'); + } + + if (balances[caller] < qty) { + throw new ContractError(`Caller balance not high enough to send ${qty} token(s)!`); + } + + // Lower the token balance of the caller + balances[caller] -= qty; + if (target in balances) { + // Wallet already exists in state, add new tokens + balances[target] += qty; + } else { + // Wallet is new, set starting balance + balances[target] = qty; + } + + return { state }; + } + + if (input.function === 'balance') { + const target = input.target; + const ticker = state.ticker; + + if (typeof target !== 'string') { + throw new ContractError('Must specify target to get balance for'); + } + + if (typeof balances[target] !== 'number') { + throw new ContractError('Cannot get balance, target does not exist'); + } + + return { result: { target, ticker, balance: balances[target] + 555 } }; + } + + if (input.function === 'evolve' && canEvolve) { + if (state.owner !== caller) { + throw new ContractError('Only the owner can evolve a contract.'); + } + + state.evolve = input.value; + + return { state }; + } + + throw new ContractError(`No function supplied or function not recognised: "${input.function}"`); +} diff --git a/src/__tests__/integration/data/token-pst.js b/src/__tests__/integration/data/token-pst.js index 733959f..c1414c5 100644 --- a/src/__tests__/integration/data/token-pst.js +++ b/src/__tests__/integration/data/token-pst.js @@ -1,67 +1,66 @@ - -export function handle (state, action) { - const balances = state.balances - const canEvolve = state.canEvolve - const input = action.input - const caller = action.caller +export function handle(state, action) { + const balances = state.balances; + const canEvolve = state.canEvolve; + const input = action.input; + const caller = action.caller; if (input.function === 'transfer') { - const target = input.target - const qty = input.qty + const target = input.target; + const qty = input.qty; if (!Number.isInteger(qty)) { - throw new ContractError('Invalid value for "qty". Must be an integer') + throw new ContractError('Invalid value for "qty". Must be an integer'); } if (!target) { - throw new ContractError('No target specified') + throw new ContractError('No target specified'); } if (qty <= 0 || caller === target) { - throw new ContractError('Invalid token transfer') + throw new ContractError('Invalid token transfer'); } if (balances[caller] < qty) { - throw new ContractError(`Caller balance not high enough to send ${qty} token(s)!`) + throw new ContractError(`Caller balance not high enough to send ${qty} token(s)!`); } // Lower the token balance of the caller - balances[caller] -= qty + balances[caller] -= qty; if (target in balances) { // Wallet already exists in state, add new tokens - balances[target] += qty + balances[target] += qty; } else { // Wallet is new, set starting balance - balances[target] = qty + balances[target] = qty; } - return { state } + return { state }; } if (input.function === 'balance') { - const target = input.target - const ticker = state.ticker + const target = input.target; + const ticker = state.ticker; if (typeof target !== 'string') { - throw new ContractError('Must specify target to get balance for') + throw new ContractError('Must specify target to get balance for'); } if (typeof balances[target] !== 'number') { - throw new ContractError('Cannot get balance, target does not exist') + throw new ContractError('Cannot get balance, target does not exist'); } - return { result: { target, ticker, balance: balances[target] } } + return { result: { target, ticker, balance: balances[target] } }; } - if(input.function === 'evolve' && canEvolve) { - if(state.owner !== caller) { + if (input.function === 'evolve' && canEvolve) { + if (state.owner !== caller) { throw new ContractError('Only the owner can evolve a contract.'); } - state.evolve = input.value + state.evolve = input.value; - return { state } + return { state }; } - throw new ContractError(`No function supplied or function not recognised: "${input.function}"`) + throw new ContractError(`No function supplied or function not recognised: "${input.function}"`); } diff --git a/src/__tests__/integration/deploy-write-read.test.ts b/src/__tests__/integration/deploy-write-read.test.ts index 240c41a..43d53bc 100644 --- a/src/__tests__/integration/deploy-write-read.test.ts +++ b/src/__tests__/integration/deploy-write-read.test.ts @@ -32,6 +32,8 @@ describe('Testing the SmartWeave client', () => { let walletAddress: string; beforeAll(async () => { + // note: each tests suit (i.e. file with tests that Jest is running concurrently + // with another files has to have ArLocal set to a different port!) arlocal = new ArLocal(1985, false); await arlocal.start(); @@ -58,7 +60,7 @@ describe('Testing the SmartWeave client', () => { src: contractSrc }); - contract = smartweave.contract(contractTxId) as HandlerBasedContract; + contract = smartweave.contract(contractTxId); contract.connect(wallet); await mine(); diff --git a/src/__tests__/integration/pst.test.ts b/src/__tests__/integration/pst.test.ts index 93a6557..e9ab004 100644 --- a/src/__tests__/integration/pst.test.ts +++ b/src/__tests__/integration/pst.test.ts @@ -20,12 +20,14 @@ describe('Testing the Profit Sharing Token', () => { let initialState: PstState; beforeAll(async () => { - arlocal = new ArLocal(1985, false); + // note: each tests suit (i.e. file with tests that Jest is running concurrently + // with another files has to have ArLocal set to a different port!) + arlocal = new ArLocal(1986, false); await arlocal.start(); arweave = Arweave.init({ host: 'localhost', - port: 1985, + port: 1986, protocol: 'http' }); @@ -42,6 +44,7 @@ describe('Testing the Profit Sharing Token', () => { initialState = { ...stateFromFile, ...{ + owner: walletAddress, balances: { ...stateFromFile.balances, [walletAddress]: 555669 @@ -95,6 +98,21 @@ describe('Testing the Profit Sharing Token', () => { expect(result.ticker).toEqual('EXAMPLE_PST_TOKEN'); expect(result.target).toEqual('uhE-QeYS8i4pmUtnxQyHD7dzXFNaJ9oMK-IM-QPNY6M'); }); + + it("should properly evolve contract's source code", async () => { + expect((await pst.currentState()).balances[walletAddress]).toEqual(555114); + + const newSource = fs.readFileSync(path.join(__dirname, 'data/token-evolve.js'), 'utf8'); + + const newSrcTxId = await pst.saveNewSource(newSource); + await mine(); + + await pst.evolve(newSrcTxId); + await mine(); + + // note: the evolved balance always adds 555 to the result + expect((await pst.currentBalance(walletAddress)).balance).toEqual(555114 + 555); + }); }); async function mine() { diff --git a/src/contract/Contract.ts b/src/contract/Contract.ts index 22dcff6..b230034 100644 --- a/src/contract/Contract.ts +++ b/src/contract/Contract.ts @@ -61,6 +61,9 @@ export interface Contract { * * note: calling "interactRead" from withing contract's source code was not previously possible - * this is a new feature. + * + * TODO: this should not be exposed in a public API - as it is supposed + * to be used only by Handler code. */ viewStateForTx( input: Input, diff --git a/src/contract/HandlerBasedContract.ts b/src/contract/HandlerBasedContract.ts index 6472f5e..dec4c85 100644 --- a/src/contract/HandlerBasedContract.ts +++ b/src/contract/HandlerBasedContract.ts @@ -30,13 +30,13 @@ import { NetworkInfoInterface } from 'arweave/node/network'; export class HandlerBasedContract implements Contract { private readonly logger = LoggerFactory.INST.create('HandlerBasedContract'); - private wallet?: ArWallet; + protected wallet?: ArWallet; private evaluationOptions: EvaluationOptions = new DefaultEvaluationOptions(); public networkInfo?: NetworkInfoInterface = null; constructor( readonly contractTxId: string, - private readonly smartweave: SmartWeave, + protected readonly smartweave: SmartWeave, // note: this will be probably used for creating contract's // call hierarchy and generating some sort of "stack trace" private readonly callingContract: Contract = null diff --git a/src/contract/PstContract.ts b/src/contract/PstContract.ts index 284aebc..210c91b 100644 --- a/src/contract/PstContract.ts +++ b/src/contract/PstContract.ts @@ -1,20 +1,49 @@ -import { Contract, InteractionResult } from '@smartweave'; +import { Contract } from '@smartweave'; +/** + * The result from the "balance" view method on the PST Contract. + */ export interface BalanceResult { target: string; ticker: string; balance: number; } -export interface PstState { +/** + * Interface for all contracts the implement the {@link Evolve} feature + */ +export interface EvolvingContract { + saveNewSource(newContractSource: string): Promise; + + evolve(newSrcTxId: string): Promise; +} + +/** + * Interface describing state for all Evolve-compatible contracts. + * Evolve is a feature that allows to change contract's source + * code, without deploying a new contract. + * See ({@link Evolve}) + */ +export interface EvolveState { + settings: any[] | {} | null; + canEvolve: boolean; // whether contract is allowed to evolve. seems to default to true.. + evolve: string; // the transaction id of the Arweave transaction with the updated source code. odd naming convention.. +} + +/** + * Interface describing state for all PST contracts. + */ +export interface PstState extends EvolveState { ticker: string; owner: string; - canEvolve: boolean; balances: { [key: string]: number; }; } +/** + * Interface describing data required for making a transfer + */ export interface TransferInput { target: string; qty: number; @@ -24,7 +53,7 @@ export interface TransferInput { * A type of {@link Contract} designed specifically for the interaction with * Profit Sharing Tokens. */ -export interface PstContract extends Contract { +export interface PstContract extends Contract, EvolvingContract { currentBalance(target: string): Promise; currentState(): Promise; diff --git a/src/contract/PstContractImpl.ts b/src/contract/PstContractImpl.ts index cbc21b7..a558b71 100644 --- a/src/contract/PstContractImpl.ts +++ b/src/contract/PstContractImpl.ts @@ -1,3 +1,4 @@ +import { SmartWeaveTags } from '@smartweave'; import { BalanceResult, HandlerBasedContract, PstContract, PstState, TransferInput } from '@smartweave/contract'; interface BalanceInput { @@ -7,7 +8,7 @@ interface BalanceInput { export class PstContractImpl extends HandlerBasedContract implements PstContract { async currentBalance(target: string): Promise { - const interactionResult = await super.viewState({ function: 'balance', target }); + const interactionResult = await this.viewState({ function: 'balance', target }); if (interactionResult.type !== 'ok') { throw Error(interactionResult.errorMessage); } @@ -19,6 +20,27 @@ export class PstContractImpl extends HandlerBasedContract implements P } async transfer(transfer: TransferInput): Promise { - return await super.writeInteraction({ function: 'transfer', ...transfer }); + return await this.writeInteraction({ function: 'transfer', ...transfer }); + } + + async evolve(newSrcTxId: string): Promise { + return await this.writeInteraction({ function: 'evolve', value: newSrcTxId }); + } + + async saveNewSource(newContractSource: string): Promise { + if (!this.wallet) { + throw new Error("Wallet not connected. Use 'connect' method first."); + } + const { arweave } = this.smartweave; + + const tx = await arweave.createTransaction({ data: newContractSource }, this.wallet); + tx.addTag(SmartWeaveTags.APP_NAME, 'SmartWeaveContractSource'); + tx.addTag(SmartWeaveTags.APP_VERSION, '0.3.0'); + tx.addTag('Content-Type', 'application/javascript'); + + await arweave.transactions.sign(tx, this.wallet); + await arweave.transactions.post(tx); + + return tx.id; } } diff --git a/src/plugins/CacheableStateEvaluator.ts b/src/plugins/CacheableStateEvaluator.ts index 729aecc..5c3c660 100644 --- a/src/plugins/CacheableStateEvaluator.ts +++ b/src/plugins/CacheableStateEvaluator.ts @@ -47,7 +47,7 @@ export class CacheableStateEvaluator extends DefaultStateEvaluator { executionContext.contractDefinition.txId, requestedBlockHeight )) as BlockHeightCacheResult>; - logger.trace('Retrieving value from cache', benchmark.elapsed()); + this.cLogger.trace('Retrieving value from cache', benchmark.elapsed()); if (cachedState != null) { this.cLogger.debug(`Cached state for ${executionContext.contractDefinition.txId}`, { diff --git a/src/plugins/Evolve.ts b/src/plugins/Evolve.ts index b16c7d7..c744667 100644 --- a/src/plugins/Evolve.ts +++ b/src/plugins/Evolve.ts @@ -1,5 +1,6 @@ import { DefinitionLoader, + EvolveState, ExecutionContext, ExecutionContextModifier, ExecutorFactory, @@ -9,12 +10,6 @@ import { SmartWeaveErrorType } from '@smartweave'; -export interface EvolveCompatibleState { - settings: any[]; // some..erm..settings? - canEvolve: boolean; // whether contract is allowed to evolve. seems to default to true.. - evolve: string; // the transaction id of the Arweave transaction with the updated source code. odd naming convention.. -} - /* ...I'm still not fully convinced to the whole "evolve" idea. @@ -33,7 +28,7 @@ without the need of hard-coding contract's txId in the client's source code. This also makes it easier to audit given contract - as you keep all its versions in one place. */ -function isEvolveCompatible(state: any): state is EvolveCompatibleState { +function isEvolveCompatible(state: any): state is EvolveState { if (!state) { return false; } @@ -90,17 +85,18 @@ export class Evolve implements ExecutionContextModifier { const newContractDefinition = await this.definitionLoader.load(contractTxId, evolve); const newHandler = (await this.executorFactory.create(newContractDefinition)) as HandlerApi; - const modifiedContext = { - ...executionContext, - contractDefinition: newContractDefinition, - handler: newHandler - }; + //FIXME: side-effect... + executionContext.contractDefinition = newContractDefinition; + executionContext.handler = newHandler; + this.logger.debug('evolved to:', { - txId: modifiedContext.contractDefinition.txId, - srcTxId: modifiedContext.contractDefinition.srcTxId + evolve: evolve, + newSrcTxId: executionContext.contractDefinition.srcTxId, + current: currentSrcTxId, + txId: executionContext.contractDefinition.txId, }); - return modifiedContext; + return executionContext; } catch (e) { throw new SmartWeaveError(SmartWeaveErrorType.CONTRACT_NOT_FOUND, { message: `Contract having txId: ${contractTxId} not found`,