feat: 323 add constructor for js contracts

This commit is contained in:
Michał Konopka
2023-03-06 11:24:51 +01:00
committed by Mike
parent e077b9b061
commit 89428bba9c
16 changed files with 493 additions and 49 deletions

View File

@@ -34,6 +34,7 @@
"test:unit": "jest ./src/__tests__/unit",
"test:unit:cache": "jest ./src/__tests__/unit/cache-leveldb.test.ts",
"test:unit:cache:real": "jest ./src/__tests__/unit/cache-leveldb-real-data.test.ts",
"test:integration": "jest ./src/__tests__/integration",
"test:integration:basic": "jest ./src/__tests__/integration/basic",
"test:integration:basic:load": "jest --silent=false --detectOpenHandles ./src/__tests__/integration/basic/contract-loading.test.ts ",
"test:integration:basic:arweave": "jest ./src/__tests__/integration/basic/arweave-transactions-loading",
@@ -127,4 +128,4 @@
"process": false,
"url": false
}
}
}

View File

@@ -0,0 +1,267 @@
import fs from 'fs';
import ArLocal from 'arlocal';
import { JWKInterface } from 'arweave/node/lib/wallet';
import path from 'path';
import { Warp } from '../../../core/Warp';
import { WarpFactory } from '../../../core/WarpFactory';
import { LoggerFactory } from '../../../logging/LoggerFactory';
import { DeployPlugin } from 'warp-contracts-plugin-deploy';
import { mineBlock } from '../_helpers';
describe('Constructor', () => {
let contractSrc: string;
let contractIrSrc: string;
let helperContractSrc: string;
let dummyContractSrc: string;
let wallet: JWKInterface;
let walletAddress: string;
const initialState: Record<string, number> = {
counter: 1
};
let arlocal: ArLocal;
let warp: Warp;
beforeAll(async () => {
arlocal = new ArLocal(1332, false);
await arlocal.start();
LoggerFactory.INST.logLevel('error');
warp = WarpFactory.forLocal(1332).use(new DeployPlugin());
({ jwk: wallet, address: walletAddress } = await warp.generateWallet());
contractSrc = fs.readFileSync(path.join(__dirname, '../data/constructor/constructor.js'), 'utf8');
contractIrSrc = fs.readFileSync(path.join(__dirname, '../data/constructor/constructor-internal-writes.js'), 'utf8');
helperContractSrc = fs.readFileSync(path.join(__dirname, '../data/constructor/constructor-helper.js'), 'utf8');
dummyContractSrc = fs.readFileSync(path.join(__dirname, '../data/constructor/constructor-dummy.js'), 'utf8');
});
afterAll(async () => {
await arlocal.stop();
});
const deployContract = async ({ withConstructor = true, withKv = true, addToState = {}, src = contractSrc }) => {
const { contractTxId } = await warp.deploy({
wallet,
initState: JSON.stringify({ ...initialState, ...addToState }),
src: src,
evaluationManifest: withConstructor
? {
evaluationOptions: {
useConstructor: true,
useKVStorage: withKv,
internalWrites: !withKv,
ignoreExceptions: false
}
}
: undefined
});
const contract = warp
.contract<any>(contractTxId)
.setEvaluationOptions(
withConstructor
? { useConstructor: true, internalWrites: !withKv, ignoreExceptions: false }
: { ignoreExceptions: false }
)
.connect(wallet);
await mineBlock(warp);
return contract;
};
describe('with useConstructor = true', () => {
describe('0 interactions', () => {
it('should call constructor on first read state and works with next readStates', async () => {
const contract = await deployContract({});
const {
cachedValue: { state }
} = await contract.readState();
expect(state.calls).toBeDefined();
expect(state.calls).toEqual(['__init']);
const {
cachedValue: { state: state2 }
} = await contract.readState();
expect(state2.calls).toBeDefined();
expect(state2.calls).toEqual(['__init']);
});
});
describe('with missing interactions', () => {
it('should call constructor on first read state and works with next readStates', async () => {
const contract = await deployContract({});
await contract.writeInteraction({ function: 'nop' });
const {
cachedValue: { state }
} = await contract.readState();
expect(state.calls).toBeDefined();
expect(state.calls).toEqual(['__init', 'nop']);
await contract.writeInteraction({ function: 'nop' });
await contract.writeInteraction({ function: 'nop' });
const {
cachedValue: { state: state2 }
} = await contract.readState();
expect(state2.calls).toBeDefined();
expect(state2.calls).toEqual(['__init', 'nop', 'nop', 'nop']);
});
});
describe('Constructor has access to all smartweave globals', () => {
it('should assign as caller deployer of contract', async () => {
const contract = await deployContract({});
const {
cachedValue: { state }
} = await contract.readState();
expect(state.caller).toEqual(walletAddress);
expect(state.caller2).toEqual(walletAddress);
await contract.writeInteraction({ function: 'nop' });
const {
cachedValue: { state: state2 }
} = await contract.readState();
expect(state2.caller).toEqual(walletAddress);
expect(state2.caller2).toEqual(walletAddress);
});
it('should work with KV', async () => {
const contract = await deployContract({});
await contract.readState();
const { cachedValue: kv } = await contract.getStorageValues(['__init']);
expect(kv.get('__init')).toEqual(contract.txId());
});
});
it('should rollback KV and state', async () => {
const contract = await deployContract({ addToState: { fail: true } });
await expect(contract.readState()).rejects.toThrowError();
const { cachedValue: kv } = await contract.getStorageValues(['__init']);
expect(kv.get('__init')).toEqual(undefined);
});
it('should fail to call __init function explicit', async () => {
const contract = await deployContract({});
await expect(contract.writeInteraction({ function: '__init' }, { strict: true })).rejects.toThrowError();
});
it('should properly apply modifications from __init', async () => {
const contract = await deployContract({});
const {
cachedValue: { state }
} = await contract.readState();
expect(state.counter).toBe(2);
await contract.writeInteraction({ function: 'nop' });
await contract.writeInteraction({ function: 'nop' });
const {
cachedValue: { state: state2 }
} = await contract.readState();
expect(state2.counter).toStrictEqual(2);
});
describe('Internal writes', () => {
it('should throw when using internal writes in contract in __init', async () => {
const writesInConstructorContract = await deployContract({
src: helperContractSrc,
withKv: false
});
await expect(writesInConstructorContract.readState()).rejects.toThrowError();
});
it('should read properly from external contract which uses constructor', async () => {
const withConstructorContract = await deployContract({
src: dummyContractSrc,
withKv: false
});
const readExternalContract = await deployContract({
src: contractIrSrc,
withKv: false,
addToState: { foreignContract: withConstructorContract.txId() }
});
expect((await readExternalContract.viewState({ function: 'read' })).result).toEqual({
originalErrorMessages: {},
originalValidity: {},
result: 100,
state: {
counter: 100
},
type: 'ok'
});
});
});
});
describe('with useConstructor = false', () => {
describe('0 interactions', () => {
it('should not call constructor on first read state', async () => {
const contract = await deployContract({ withConstructor: false });
const {
cachedValue: { state }
} = await contract.readState();
expect(state.calls).toBeUndefined();
const {
cachedValue: { state: state2 }
} = await contract.readState();
expect(state2.calls).toBeUndefined();
});
});
describe('with missing interactions', () => {
it('should not call constructor on first read state and works with next readStates', async () => {
const contract = await deployContract({ withConstructor: false });
await contract.writeInteraction({ function: 'nop' });
const {
cachedValue: { state }
} = await contract.readState();
expect(state.calls).toBeDefined();
expect(state.calls).toEqual(['nop']);
await contract.writeInteraction({ function: 'nop' });
await contract.writeInteraction({ function: 'nop' });
const {
cachedValue: { state: state2 }
} = await contract.readState();
expect(state2.calls).toBeDefined();
expect(state2.calls).toEqual(['nop', 'nop', 'nop']);
});
});
it('should NOT fail to call __init function', async () => {
const contract = await deployContract({ withConstructor: false });
await expect(contract.writeInteraction({ function: '__init' })).resolves.toBeDefined();
});
});
});

View File

@@ -0,0 +1,10 @@
export async function handle(state, action) {
if (action.input.function == '__init') {
state.counter = 100;
return { state };
}
if (action.input.function === 'readCounter') {
return { result: state.counter };
}
}

View File

@@ -0,0 +1,12 @@
export async function handle(state, action) {
if (action.input.function == '__init') {
state.counter = 100;
await SmartWeave.contracts.write(action.input.args.foreignContract, { function: 'write' });
return { state };
}
if (action.input.function == 'write') {
state.counter = (state.counter || 0) + 1;
return { state }
}
}

View File

@@ -0,0 +1,24 @@
export async function handle(state, action) {
state.calls = state.calls || [];
state.calls = [...state.calls, action.input.function]
if (action.input.function == '__init') {
state.caller = action.caller;
state.caller2 = SmartWeave.caller;
if (action.input.args.fail) {
throw new ContractError("Fail on purpose")
}
state.counter = action.input.args.counter + 1;
state.foreignContract = action.input.args.foreignContract;
} else if (action.input.function == 'readCounter') {
return { result: state.counter }
}
else if (action.input.function == 'read') {
const result = await SmartWeave.contracts.viewContractState(state.foreignContract, { function: 'readCounter' });
return { result };
}
return { state }
}

View File

@@ -0,0 +1,18 @@
export async function handle(state, action) {
state.calls = state.calls || [];
state.calls = [...state.calls, action.input.function]
if (action.input.function == '__init') {
state.caller = action.caller;
state.caller2 = SmartWeave.caller;
await SmartWeave.kv.put("__init", SmartWeave.transaction.id);
if (action.input.args.fail) {
throw new ContractError("Fail on purpose")
}
state.counter = action.input.args.counter + 1;
}
return { state }
}

View File

@@ -30,6 +30,7 @@ describe('Evaluation options evaluator', () => {
useKVStorage: false,
useVM2: false,
waitForConfirmation: false,
useConstructor: false,
walletBalanceUrl: 'http://nyc-1.dev.arweave.net:1984/'
});
@@ -69,6 +70,7 @@ describe('Evaluation options evaluator', () => {
useKVStorage: false,
useVM2: true,
waitForConfirmation: false,
useConstructor: false,
walletBalanceUrl: 'http://nyc-1.dev.arweave.net:1984/'
});
@@ -102,6 +104,7 @@ describe('Evaluation options evaluator', () => {
useKVStorage: false,
useVM2: true,
waitForConfirmation: false,
useConstructor: false,
walletBalanceUrl: 'http://nyc-1.dev.arweave.net:1984/'
});

View File

@@ -106,9 +106,16 @@ export class EvaluationOptionsEvaluator {
cacheEveryNInteractions: () => this.rootOptions['cacheEveryNInteractions'],
remoteStateSyncEnabled: () => this.rootOptions['remoteStateSyncEnabled'],
remoteStateSyncSource: () => this.rootOptions['remoteStateSyncSource'],
useKVStorage: (foreignOptions) => foreignOptions['useKVStorage']
useKVStorage: (foreignOptions) => foreignOptions['useKVStorage'],
useConstructor: (foreignOptions) => foreignOptions['useConstructor']
};
private readonly notConflictingEvaluationOptions: (keyof EvaluationOptions)[] = [
'useKVStorage',
'sourceType',
'useConstructor'
];
/**
* @param userSetOptions evaluation options set via {@link Contract.setEvaluationOptions}
* @param manifestOptions evaluation options from the root contract's manifest (i.e. the contract that
@@ -118,7 +125,7 @@ export class EvaluationOptionsEvaluator {
if (manifestOptions) {
const errors = [];
for (const k in manifestOptions) {
if (['useKVStorage', 'sourceType'].includes(k)) {
if (this.notConflictingEvaluationOptions.includes(k as keyof EvaluationOptions)) {
continue;
}
if (userSetOptions[k] !== manifestOptions[k]) {

View File

@@ -39,7 +39,7 @@ export type ExecutionContext<State, Api = unknown> = {
* A handle to the contract's "handle" function - ie. main function of the given SWC - that actually
* performs all the computation.
*/
handler: Api;
handler?: Api;
caller?: string; // note: this is only set for "viewState" and "write" operations
cachedState?: SortKeyCacheResult<EvalStateResult<State>>;
requestedSortKey?: string;

View File

@@ -99,7 +99,7 @@ export class EvalStateResult<State> {
readonly state: State,
readonly validity: Record<string, boolean>,
readonly errorMessages: Record<string, string>
) {}
) { }
}
export type UnsafeClientOptions = 'allow' | 'skip' | 'throw';
@@ -151,6 +151,8 @@ export class DefaultEvaluationOptions implements EvaluationOptions {
remoteStateSyncEnabled = false;
remoteStateSyncSource = 'https://dre-1.warp.cc/contract';
useConstructor = false;
}
// an interface for the contract EvaluationOptions - can be used to change the behaviour of some features.
@@ -236,6 +238,10 @@ export interface EvaluationOptions {
// whether a separate key-value storage should be used for the contract
useKVStorage: boolean;
// If set to true, __init function will be called before any interaction on first evaluation of contract.
// Contract has to expose __init function in handler.
useConstructor: boolean;
// whether contract state should be acquired from remote source, e.g. D.R.E.
remoteStateSyncEnabled: boolean;

View File

@@ -49,28 +49,34 @@ export class CacheableStateEvaluator extends DefaultStateEvaluator {
throw new Error('Contract tx id not set in the execution context');
}
const isFirstEvaluation = cachedState == null;
let baseState = isFirstEvaluation ? executionContext.contractDefinition.initState : cachedState.cachedValue.state;
const baseValidity = isFirstEvaluation ? {} : cachedState.cachedValue.validity;
const baseErrorMessages = isFirstEvaluation ? {} : cachedState.cachedValue.errorMessages;
if (isFirstEvaluation) {
baseState = await executionContext.handler.maybeCallStateConstructor(
executionContext.contractDefinition.initState,
executionContext
);
}
if (missingInteractions.length == 0) {
this.cLogger.info(`No missing interactions ${contractTxId}`);
if (cachedState) {
if (!isFirstEvaluation) {
executionContext.handler?.initState(cachedState.cachedValue.state);
return cachedState;
} else {
executionContext.handler?.initState(executionContext.contractDefinition.initState);
executionContext.handler?.initState(baseState);
this.cLogger.debug('Inserting initial state into cache');
const stateToCache = new EvalStateResult(executionContext.contractDefinition.initState, {}, {});
const stateToCache = new EvalStateResult(baseState, {}, {});
// no real sort-key - as we're returning the initial state
await this.cache.put(new CacheKey(contractTxId, genesisSortKey), stateToCache);
return new SortKeyCacheResult<EvalStateResult<State>>(genesisSortKey, stateToCache);
}
}
const baseState =
cachedState == null ? executionContext.contractDefinition.initState : cachedState.cachedValue.state;
const baseValidity = cachedState == null ? {} : cachedState.cachedValue.validity;
const baseErrorMessages = cachedState == null ? {} : cachedState.cachedValue.errorMessages;
// eval state for the missing transactions - starting from the latest value from cache.
return await this.doReadState(
missingInteractions,

View File

@@ -59,7 +59,6 @@ export abstract class DefaultStateEvaluator implements StateEvaluator {
// TODO: opt - reuse wasm handlers
executionContext?.handler.initState(currentState);
const depth = executionContext.contract.callDepth();
this.logger.debug(
@@ -229,6 +228,7 @@ export abstract class DefaultStateEvaluator implements StateEvaluator {
new EvalStateResult(currentState, validity, errorMessages),
interactionData
);
errorMessage = result.errorMessage;
if (result.type !== 'ok') {
errorMessages[missingInteraction.id] = errorMessage;

View File

@@ -258,6 +258,8 @@ export interface HandlerApi<State> {
): Promise<InteractionResult<State, Result>>;
initState(state: State): void;
maybeCallStateConstructor(initialState: State, executionContext: ExecutionContext<State>): Promise<State>;
}
export type HandlerFunction<State, Input, Result> = (

View File

@@ -28,6 +28,11 @@ export abstract class AbstractContractHandler<State> implements HandlerApi<State
abstract initState(state: State): void;
abstract maybeCallStateConstructor(
initialState: State,
executionContext: ExecutionContext<State, unknown>
): Promise<State>;
async dispose(): Promise<void> {
// noop by default;
}

View File

@@ -1,11 +1,24 @@
import { GQLNodeInterface } from 'legacy/gqlResult';
import { ContractDefinition } from '../../../../core/ContractDefinition';
import { ExecutionContext } from '../../../../core/ExecutionContext';
import { EvalStateResult } from '../../../../core/modules/StateEvaluator';
import { SmartWeaveGlobal } from '../../../../legacy/smartweave-global';
import { deepCopy, timeout } from '../../../../utils/utils';
import { InteractionData, InteractionResult } from '../HandlerExecutorFactory';
import { ContractInteraction, InteractionData, InteractionResult } from '../HandlerExecutorFactory';
import { genesisSortKey } from '../LexicographicalInteractionsSorter';
import { AbstractContractHandler } from './AbstractContractHandler';
const INIT_FUNC_NAME = '__init';
const throwErrorWithName = (name: string, message: string) => {
const error = new Error(message);
error.name = name;
throw error;
};
enum KnownErrors {
ContractError = 'ContractError',
ConstructorError = 'ConstructorError'
}
export class JsHandlerApi<State> extends AbstractContractHandler<State> {
constructor(
swGlobal: SmartWeaveGlobal,
@@ -21,31 +34,87 @@ export class JsHandlerApi<State> extends AbstractContractHandler<State> {
currentResult: EvalStateResult<State>,
interactionData: InteractionData<Input>
): Promise<InteractionResult<State, Result>> {
const { interaction, interactionTx } = interactionData;
this.setupSwGlobal(interactionData);
this.enableInternalWrites(executionContext, interactionTx);
this.assertNotConstructorCall<Input>(interaction);
return await this.runContractFunction(executionContext, interaction, currentResult.state);
}
// eslint-disable-next-line
initState(state: State): void {}
async maybeCallStateConstructor<Input>(
initialState: State,
executionContext: ExecutionContext<State>
): Promise<State> {
if (this.contractDefinition.manifest?.evaluationOptions.useConstructor) {
const interaction = {
input: { function: INIT_FUNC_NAME, args: initialState } as Input,
caller: this.contractDefinition.owner
};
const interactionTx = { ...this.contractDefinition.contractTx, sortKey: genesisSortKey };
// this is hard corded sortKey to make KV possible
const interactionData: InteractionData<Input> = { interaction, interactionTx };
this.setupSwGlobal(interactionData);
this.disableInternalWritesForConstructor();
const result = await this.runContractFunction(executionContext, interaction, {} as State);
if (result.type !== 'ok') {
throw new Error(`Exception while calling constructor: ${JSON.stringify(interaction)}:\n${result.errorMessage}`);
}
return result.state;
} else {
return initialState;
}
}
private assertNotConstructorCall<Input>(interaction: ContractInteraction<Input>) {
if (
this.contractDefinition.manifest?.evaluationOptions.useConstructor &&
interaction.input['function'] === INIT_FUNC_NAME
) {
throw new Error(`You have enabled {useConstructor: true} option, so you can't call function ${INIT_FUNC_NAME}`);
}
}
private disableInternalWritesForConstructor() {
const templateErrorMessage = (op) =>
`Can't ${op} foreign contract state: Internal writes feature is not available in constructor`;
this.swGlobal.contracts.readContractState = () =>
throwErrorWithName('ConstructorError', templateErrorMessage('readContractState'));
this.swGlobal.contracts.write = () => throwErrorWithName('ConstructorError', templateErrorMessage('write'));
this.swGlobal.contracts.refreshState = () =>
throwErrorWithName('ConstructorError', templateErrorMessage('refreshState'));
this.swGlobal.contracts.viewContractState = () =>
throwErrorWithName('ConstructorError', templateErrorMessage('viewContractState'));
}
private async runContractFunction<Input>(
executionContext: ExecutionContext<State>,
interaction: InteractionData<Input>['interaction'],
state: State
) {
const stateClone = deepCopy(state);
const { timeoutId, timeoutPromise } = timeout(
executionContext.evaluationOptions.maxInteractionEvaluationTimeSeconds
);
try {
const { interaction, interactionTx } = interactionData;
const stateCopy = deepCopy(currentResult.state);
this.swGlobal._activeTx = interactionTx;
this.swGlobal.caller = interaction.caller; // either contract tx id (for internal writes) or transaction.owner
this.assignReadContractState(executionContext, interactionTx);
this.assignViewContractState<Input>(executionContext);
this.assignWrite(executionContext);
this.assignRefreshState(executionContext);
await this.swGlobal.kv.open();
const handlerResult = await Promise.race([timeoutPromise, this.contractFunction(stateCopy, interaction)]);
const handlerResult = await Promise.race([timeoutPromise, this.contractFunction(stateClone, interaction)]);
if (handlerResult && (handlerResult.state !== undefined || handlerResult.result !== undefined)) {
await this.swGlobal.kv.commit();
return {
type: 'ok',
type: 'ok' as const,
result: handlerResult.result,
state: handlerResult.state || currentResult.state
state: handlerResult.state || stateClone
};
}
@@ -54,39 +123,43 @@ export class JsHandlerApi<State> extends AbstractContractHandler<State> {
} catch (err) {
await this.swGlobal.kv.rollback();
switch (err.name) {
case 'ContractError':
case KnownErrors.ContractError:
return {
type: 'error',
type: 'error' as const,
errorMessage: err.message,
state: currentResult.state,
// note: previous version was writing error message to a "result" field,
// which fucks-up the HandlerResult type definition -
// HandlerResult.result had to be declared as 'Result | string' - and that led to a poor dev exp.
// TODO: this might be breaking change!
state: state,
result: null
};
case KnownErrors.ConstructorError:
throw Error(`ConstructorError: ${err.message}`);
default:
return {
type: 'exception',
type: 'exception' as const,
errorMessage: `${(err && err.stack) || (err && err.message) || err}`,
state: currentResult.state,
state: state,
result: null
};
}
} finally {
await this.swGlobal.kv.close();
if (timeoutId !== null) {
// it is important to clear the timeout promise
// - promise.race won't "cancel" it automatically if the "handler" promise "wins"
// - and this would ofc. cause a waste in cpu cycles
// (+ Jest complains about async operations not being stopped properly).
if (timeoutId) {
clearTimeout(timeoutId);
}
await this.swGlobal.kv.close();
}
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
initState(state: State): void {
// nth to do in this impl...
private setupSwGlobal<Input>({ interaction, interactionTx }: InteractionData<Input>) {
this.swGlobal._activeTx = interactionTx;
this.swGlobal.caller = interaction.caller; // either contract tx id (for internal writes) or transaction.owner
}
private enableInternalWrites<Input>(
executionContext: ExecutionContext<State, unknown>,
interactionTx: GQLNodeInterface
) {
this.assignReadContractState(executionContext, interactionTx);
this.assignViewContractState<Input>(executionContext);
this.assignWrite(executionContext);
this.assignRefreshState(executionContext);
}
}

View File

@@ -125,6 +125,16 @@ export class WasmHandlerApi<State> extends AbstractContractHandler<State> {
}
}
async maybeCallStateConstructor(
initialState: State,
executionContext: ExecutionContext<State, unknown>
): Promise<State> {
if (this.contractDefinition.manifest?.evaluationOptions.useConstructor) {
throw Error('Constructor is not implemented for wasm');
}
return initialState;
}
private doGetCurrentState(): State {
switch (this.contractDefinition.srcWasmLang) {
case 'rust': {