diff --git a/src/__tests__/integration/internal-writes/internal-write-depth.test.ts b/src/__tests__/integration/internal-writes/internal-write-depth.test.ts index 42c6603..b47bdcc 100644 --- a/src/__tests__/integration/internal-writes/internal-write-depth.test.ts +++ b/src/__tests__/integration/internal-writes/internal-write-depth.test.ts @@ -119,7 +119,7 @@ describe('Testing internal writes', () => { contractA = smartweave .contract(contractATxId) .setEvaluationOptions({ - internalWrites: true + internalWrites: true, }) .connect(wallet); contractB = smartweave @@ -271,6 +271,56 @@ describe('Testing internal writes', () => { }); }); + describe('with different maxDepths', () => { + beforeEach(async () => { + await deployContracts(); + }); + + it('should properly evaluate contractC state for maxDepth = 3', async () => { + contractC.setEvaluationOptions({ + maxCallDepth: 3 + }); + + await contractB.writeInteraction({ function: 'add' }); + await contractB.writeInteraction({ function: 'add' }); + await contractC.writeInteraction({ function: 'add' }); + await mine(); + + await contractA.writeInteraction({ + function: 'writeInDepth', + contractId1: contractBTxId, + contractId2: contractCTxId, + amount: 10 + }); + await mine(); + + expect((await contractC.readState()).state.counter).toEqual(231); + expect((await contractC.readState()).state.counter).toEqual(231); + }); + + it('should throw when evaluating ContractC state for maxDepth = 2', async () => { + contractC.setEvaluationOptions({ + maxCallDepth: 2, + ignoreExceptions: false + }); + + await contractB.writeInteraction({ function: 'add' }); + await contractB.writeInteraction({ function: 'add' }); + await contractC.writeInteraction({ function: 'add' }); + await mine(); + + await contractA.writeInteraction({ + function: 'writeInDepth', + contractId1: contractBTxId, + contractId2: contractCTxId, + amount: 10 + }); + await mine(); + + await expect(contractC.readState()).rejects.toThrow(/(.)*Error: Max call depth(.*)/); + }); + }); + async function mine() { await arweave.api.get('mine'); } diff --git a/src/contract/Contract.ts b/src/contract/Contract.ts index 20c8061..7644d4c 100644 --- a/src/contract/Contract.ts +++ b/src/contract/Contract.ts @@ -115,4 +115,10 @@ export interface Contract { getNetworkInfo(): NetworkInfoInterface; getRootBlockHeight(): number | null; + + parent(): Contract | null; + + callDepth(): number; + + evaluationOptions(): EvaluationOptions; } diff --git a/src/contract/HandlerBasedContract.ts b/src/contract/HandlerBasedContract.ts index 2dc3711..13ad5de 100644 --- a/src/contract/HandlerBasedContract.ts +++ b/src/contract/HandlerBasedContract.ts @@ -37,19 +37,21 @@ import { NetworkInfoInterface } from 'arweave/node/network'; export class HandlerBasedContract implements Contract { private readonly logger = LoggerFactory.INST.create('HandlerBasedContract'); - private callStack: ContractCallStack; - private evaluationOptions: EvaluationOptions = new DefaultEvaluationOptions(); + private _callStack: ContractCallStack; + private _evaluationOptions: EvaluationOptions = new DefaultEvaluationOptions(); /** * current Arweave networkInfo that will be used for all operations of the SmartWeave protocol. * Only the 'root' contract call should read this data from Arweave - all the inner calls ("child" contracts) * should reuse this data from the parent ("calling") contract. */ - private networkInfo?: NetworkInfoInterface = null; + private _networkInfo?: NetworkInfoInterface = null; - private rootBlockHeight: number = null; + private _rootBlockHeight: number = null; - private readonly innerWritesEvaluator = new InnerWritesEvaluator(); + private readonly _innerWritesEvaluator = new InnerWritesEvaluator(); + + private readonly _callDepth: number; /** * wallet connected to this contract @@ -57,39 +59,56 @@ export class HandlerBasedContract implements Contract { protected wallet?: ArWallet; constructor( - readonly contractTxId: string, + private readonly _contractTxId: string, protected readonly smartweave: SmartWeave, - private readonly callingContract: Contract = null, - private readonly callingInteraction: GQLNodeInterface = null + private readonly _parentContract: Contract = null, + private readonly _callingInteraction: GQLNodeInterface = null ) { this.waitForConfirmation = this.waitForConfirmation.bind(this); - if (callingContract != null) { - this.networkInfo = callingContract.getNetworkInfo(); - this.rootBlockHeight = callingContract.getRootBlockHeight(); + if (_parentContract != null) { + this._networkInfo = _parentContract.getNetworkInfo(); + this._rootBlockHeight = _parentContract.getRootBlockHeight(); + this._evaluationOptions = _parentContract.evaluationOptions(); + this._callDepth = _parentContract.callDepth() + 1; + const interaction: InteractionCall = _parentContract.getCallStack().getInteraction(_callingInteraction.id); + + console.log('Call depth', { + callDepth: this._callDepth, + max: this._evaluationOptions.maxCallDepth, + options: this._evaluationOptions + }); + + if (this._callDepth > this._evaluationOptions.maxCallDepth) { + throw Error( + `Max call depth of ${this._evaluationOptions.maxCallDepth} has been exceeded for interaction ${JSON.stringify( + interaction.interactionInput + )}` + ); + } // sanity-check... - if (this.networkInfo == null) { + if (this._networkInfo == null) { throw Error('Calling contract should have the network info already set!'); } - this.logger.debug('Calling interaction id', callingInteraction.id); - const interaction: InteractionCall = callingContract.getCallStack().getInteraction(callingInteraction.id); - const callStack = new ContractCallStack(contractTxId); - interaction.interactionInput.foreignContractCalls.set(contractTxId, callStack); - this.callStack = callStack; + this.logger.debug('Calling interaction id', _callingInteraction.id); + const callStack = new ContractCallStack(_contractTxId, this._callDepth); + interaction.interactionInput.foreignContractCalls.set(_contractTxId, callStack); + this._callStack = callStack; } else { - this.callStack = new ContractCallStack(contractTxId); + this._callDepth = 0; + this._callStack = new ContractCallStack(_contractTxId, 0); } } async readState(blockHeight?: number, currentTx?: CurrentTx[]): Promise> { this.logger.info('Read state for', { - contractTxId: this.contractTxId, + contractTxId: this._contractTxId, currentTx }); this.maybeResetRootContract(blockHeight); const { stateEvaluator } = this.smartweave; const benchmark = Benchmark.measure(); - const executionContext = await this.createExecutionContext(this.contractTxId, blockHeight); + const executionContext = await this.createExecutionContext(this._contractTxId, blockHeight); this.logger.info('Execution Context', { blockHeight: executionContext.blockHeight, srcTxId: executionContext.contractDefinition?.srcTxId, @@ -109,7 +128,7 @@ export class HandlerBasedContract implements Contract { tags: Tags = [], transfer: ArTransfer = emptyTransfer ): Promise> { - this.logger.info('View state for', this.contractTxId); + this.logger.info('View state for', this._contractTxId); return await this.callContract(input, blockHeight, tags, transfer); } @@ -117,12 +136,12 @@ export class HandlerBasedContract implements Contract { input: Input, interactionTx: GQLNodeInterface ): Promise> { - this.logger.info(`View state for ${this.contractTxId}`, interactionTx); + this.logger.info(`View state for ${this._contractTxId}`, interactionTx); return await this.callContractForTx(input, interactionTx); } async dryWrite(input: Input, tags?: Tags, transfer?: ArTransfer): Promise> { - this.logger.info('Dry-write for', this.contractTxId); + this.logger.info('Dry-write for', this._contractTxId); return await this.callContract(input, undefined, tags, transfer); } @@ -131,8 +150,8 @@ export class HandlerBasedContract implements Contract { transaction: GQLNodeInterface, currentTx?: CurrentTx[] ): Promise> { - this.logger.info(`Dry-write from transaction ${transaction.id} for ${this.contractTxId}`); - return await this.callContractForTx(input, transaction, true, currentTx || []); + this.logger.info(`Dry-write from transaction ${transaction.id} for ${this._contractTxId}`); + return await this.callContractForTx(input, transaction, currentTx || []); } async writeInteraction( @@ -146,10 +165,10 @@ export class HandlerBasedContract implements Contract { } const { arweave } = this.smartweave; - if (this.evaluationOptions.internalWrites) { + if (this._evaluationOptions.internalWrites) { await this.callContract(input, undefined, tags, transfer); const callStack: ContractCallStack = this.getCallStack(); - const innerWrites = this.innerWritesEvaluator.eval(callStack); + const innerWrites = this._innerWritesEvaluator.eval(callStack); this.logger.debug('Input', input); this.logger.debug('Callstack', callStack.print()); @@ -166,7 +185,7 @@ export class HandlerBasedContract implements Contract { const interactionTx = await createTx( this.smartweave.arweave, this.wallet, - this.contractTxId, + this._contractTxId, input, tags, transfer.target, @@ -180,7 +199,7 @@ export class HandlerBasedContract implements Contract { return null; } - if (this.evaluationOptions.waitForConfirmation) { + if (this._evaluationOptions.waitForConfirmation) { this.logger.info('Waiting for confirmation of', interactionTx.id); const benchmark = Benchmark.measure(); await this.waitForConfirmation(interactionTx.id); @@ -190,15 +209,15 @@ export class HandlerBasedContract implements Contract { } txId(): string { - return this.contractTxId; + return this._contractTxId; } getCallStack(): ContractCallStack { - return this.callStack; + return this._callStack; } getNetworkInfo(): NetworkInfoInterface { - return this.networkInfo; + return this._networkInfo; } connect(wallet: ArWallet): Contract { @@ -207,15 +226,15 @@ export class HandlerBasedContract implements Contract { } setEvaluationOptions(options: Partial): Contract { - this.evaluationOptions = { - ...this.evaluationOptions, + this._evaluationOptions = { + ...this._evaluationOptions, ...options }; return this; } getRootBlockHeight(): number { - return this.rootBlockHeight; + return this._rootBlockHeight; } private async waitForConfirmation(transactionId: string): Promise { @@ -245,10 +264,10 @@ export class HandlerBasedContract implements Contract { const benchmark = Benchmark.measure(); // if this is a "root" call (ie. original call from SmartWeave's client) - if (this.callingContract == null) { + if (this._parentContract == null) { this.logger.debug('Reading network info for root call'); currentNetworkInfo = await arweave.network.getInfo(); - this.networkInfo = currentNetworkInfo; + this._networkInfo = currentNetworkInfo; } else { // if that's a call from within contract's source code this.logger.debug('Reusing network info from the calling contract'); @@ -258,7 +277,7 @@ export class HandlerBasedContract implements Contract { // call to contract (from contract's source code) was loading network info independently // if the contract was evaluating for many minutes/hours, this could effectively lead to reading // state on different block heights... - currentNetworkInfo = (this.callingContract as HandlerBasedContract).networkInfo; + currentNetworkInfo = (this._parentContract as HandlerBasedContract)._networkInfo; } if (blockHeight == null) { @@ -292,8 +311,8 @@ export class HandlerBasedContract implements Contract { interactionsLoader.load( contractTxId, cachedBlockHeight + 1, - this.rootBlockHeight || this.networkInfo.height, - this.evaluationOptions + this._rootBlockHeight || this._networkInfo.height, + this._evaluationOptions ) ]); this.logger.debug('contract and interactions load', benchmark.elapsed()); @@ -315,7 +334,7 @@ export class HandlerBasedContract implements Contract { handler, smartweave: this.smartweave, contract: this, - evaluationOptions: this.evaluationOptions, + evaluationOptions: this._evaluationOptions, currentNetworkInfo, cachedState }; @@ -345,7 +364,7 @@ export class HandlerBasedContract implements Contract { if (cachedBlockHeight != blockHeight) { [contractDefinition, interactions] = await Promise.all([ definitionLoader.load(contractTxId), - await interactionsLoader.load(contractTxId, 0, blockHeight, this.evaluationOptions) + await interactionsLoader.load(contractTxId, 0, blockHeight, this._evaluationOptions) ]); sortedInteractions = await interactionsSorter.sort(interactions); } else { @@ -364,18 +383,18 @@ export class HandlerBasedContract implements Contract { handler, smartweave: this.smartweave, contract: this, - evaluationOptions: this.evaluationOptions, + evaluationOptions: this._evaluationOptions, caller, cachedState }; } private maybeResetRootContract(blockHeight?: number) { - if (this.callingContract == null) { + if (this._parentContract == null) { this.logger.debug('Clearing network info and call stack for the root contract'); - this.networkInfo = null; - this.callStack = new ContractCallStack(this.txId()); - this.rootBlockHeight = blockHeight; + this._networkInfo = null; + this._callStack = new ContractCallStack(this.txId(), 0); + this._rootBlockHeight = blockHeight; } } @@ -392,7 +411,7 @@ export class HandlerBasedContract implements Contract { } const { arweave, stateEvaluator } = this.smartweave; // create execution context - let executionContext = await this.createExecutionContext(this.contractTxId, blockHeight, true); + let executionContext = await this.createExecutionContext(this._contractTxId, blockHeight, true); // add block data to execution context if (!executionContext.currentBlockData) { @@ -427,7 +446,7 @@ export class HandlerBasedContract implements Contract { const tx = await createTx( arweave, this.wallet, - this.contractTxId, + this._contractTxId, input, tags, transfer.target, @@ -458,22 +477,21 @@ export class HandlerBasedContract implements Contract { private async callContractForTx( input: Input, interactionTx: GQLNodeInterface, - dryWrite = false, currentTx?: CurrentTx[] ): Promise> { this.maybeResetRootContract(); - const executionContext = await this.createExecutionContextFromTx(this.contractTxId, interactionTx); + const executionContext = await this.createExecutionContextFromTx(this._contractTxId, interactionTx); const evalStateResult = await this.smartweave.stateEvaluator.eval(executionContext, currentTx); this.logger.debug('callContractForTx - evalStateResult', { result: evalStateResult.state, - txId: this.contractTxId + txId: this._contractTxId }); const interaction: ContractInteraction = { input, - caller: this.callingContract.txId() //executionContext.caller + caller: this._parentContract.txId() //executionContext.caller }; const interactionData: InteractionData = { @@ -482,16 +500,15 @@ export class HandlerBasedContract implements Contract { currentTx }; - return await this.evalInteraction(interactionData, executionContext, evalStateResult, dryWrite); + return await this.evalInteraction(interactionData, executionContext, evalStateResult); } private async evalInteraction( interactionData: InteractionData, executionContext: ExecutionContext>, - evalStateResult: EvalStateResult, - dryWrite = false + evalStateResult: EvalStateResult ) { - const interactionCall: InteractionCall = this.getCallStack().addInteractionData(interactionData, dryWrite); + const interactionCall: InteractionCall = this.getCallStack().addInteractionData(interactionData); const benchmark = Benchmark.measure(); const result = await executionContext.handler.handle( @@ -503,7 +520,7 @@ export class HandlerBasedContract implements Contract { interactionCall.update({ cacheHit: false, intermediaryCacheHit: false, - outputState: this.evaluationOptions.stackTrace.saveState ? result.state : undefined, + outputState: this._evaluationOptions.stackTrace.saveState ? result.state : undefined, executionTime: benchmark.elapsed(true) as number, valid: result.type === 'ok', errorMessage: result.errorMessage @@ -511,4 +528,16 @@ export class HandlerBasedContract implements Contract { return result; } + + parent(): Contract | null { + return this._parentContract; + } + + callDepth(): number { + return this._callDepth; + } + + evaluationOptions(): EvaluationOptions { + return this._evaluationOptions; + } } diff --git a/src/core/ContractCallStack.ts b/src/core/ContractCallStack.ts index ae54b20..e190475 100644 --- a/src/core/ContractCallStack.ts +++ b/src/core/ContractCallStack.ts @@ -3,9 +3,9 @@ import { InteractionData, mapReplacer } from '@smartweave'; export class ContractCallStack { readonly interactions: Map = new Map(); - constructor(public readonly contractTxId: string, public readonly label: string = '') {} + constructor(public readonly contractTxId: string, public readonly depth: number, public readonly label: string = '') {} - addInteractionData(interactionData: InteractionData, dryWrite = false): InteractionCall { + addInteractionData(interactionData: InteractionData): InteractionCall { const { interaction, interactionTx } = interactionData; const interactionCall = InteractionCall.create( diff --git a/src/core/modules/StateEvaluator.ts b/src/core/modules/StateEvaluator.ts index dc51cd8..91be1e9 100644 --- a/src/core/modules/StateEvaluator.ts +++ b/src/core/modules/StateEvaluator.ts @@ -72,11 +72,14 @@ export class DefaultEvaluationOptions implements EvaluationOptions { updateCacheForEachInteraction = true; + internalWrites = false; + + maxCallDepth = 7; // your lucky number... + stackTrace = { saveState: false }; - internalWrites: false; } // an interface for the contract EvaluationOptions - can be used to change the behaviour of some of the features. @@ -96,13 +99,22 @@ export interface EvaluationOptions { // and caches it maybe more suitable to cache only after state has been fully evaluated) updateCacheForEachInteraction: boolean; + // a new, experimental enhancement of the protocol that allows for interactWrites from + // smart contract's source code. + internalWrites: boolean; + + // maximum call depth between contracts + // eg. ContractA calls ContractB, + // then ContractB calls ContractC, + // then ContractC calls ContractD + // - call depth = 3 + // this is added as a protection from "stackoverflow" errors + maxCallDepth: number; + // a set of options that control the behaviour of the stack trace generator stackTrace: { // whether output state should be saved for each interaction in the stack trace (may result in huuuuge json files!) saveState: boolean; }; - // a new, experimental enhancement of the protocol that allows for interactWrites from - // smart contract's source code. - internalWrites: boolean; } diff --git a/src/core/modules/impl/ContractHandlerApi.ts b/src/core/modules/impl/ContractHandlerApi.ts index e166cdd..23b383e 100644 --- a/src/core/modules/impl/ContractHandlerApi.ts +++ b/src/core/modules/impl/ContractHandlerApi.ts @@ -106,9 +106,11 @@ export class ContractHandlerApi implements HandlerApi { input }); - const calleeContract = executionContext.smartweave - .contract(contractTxId, executionContext.contract, this.swGlobal._activeTx) - .setEvaluationOptions(executionContext.evaluationOptions); + const calleeContract = executionContext.smartweave.contract( + contractTxId, + executionContext.contract, + this.swGlobal._activeTx + ); const result = await calleeContract.dryWriteFromTx(input, this.swGlobal._activeTx, [ ...(currentTx || []), @@ -139,9 +141,11 @@ export class ContractHandlerApi implements HandlerApi { to: contractTxId, input }); - const childContract = executionContext.smartweave - .contract(contractTxId, executionContext.contract, this.swGlobal._activeTx) - .setEvaluationOptions(executionContext.evaluationOptions); + const childContract = executionContext.smartweave.contract( + contractTxId, + executionContext.contract, + this.swGlobal._activeTx + ); return await childContract.viewStateForTx(input, this.swGlobal._activeTx); }; @@ -167,9 +171,11 @@ export class ContractHandlerApi implements HandlerApi { }); const { stateEvaluator } = executionContext.smartweave; - const childContract = executionContext.smartweave - .contract(contractTxId, executionContext.contract, interactionTx) - .setEvaluationOptions(executionContext.evaluationOptions); + const childContract = executionContext.smartweave.contract( + contractTxId, + executionContext.contract, + interactionTx + ); await stateEvaluator.onContractCall(interactionTx, executionContext, currentResult); diff --git a/src/core/modules/impl/DefaultStateEvaluator.ts b/src/core/modules/impl/DefaultStateEvaluator.ts index 01c9e8e..5f78cf5 100644 --- a/src/core/modules/impl/DefaultStateEvaluator.ts +++ b/src/core/modules/impl/DefaultStateEvaluator.ts @@ -91,9 +91,11 @@ export class DefaultStateEvaluator implements StateEvaluator { .getCallStack() .addInteractionData({ interaction: null, interactionTx, currentTx }); - const writingContract = executionContext.smartweave - .contract(writingContractTxId, executionContext.contract, interactionTx) - .setEvaluationOptions(executionContext.evaluationOptions); + const writingContract = executionContext.smartweave.contract( + writingContractTxId, + executionContext.contract, + interactionTx + ); this.logger.debug('Reading state of the calling contract', interactionTx.block.height); await writingContract.readState(interactionTx.block.height, [ @@ -162,7 +164,7 @@ export class DefaultStateEvaluator implements StateEvaluator { this.logResult(result, interactionTx, executionContext); if (result.type === 'exception' && ignoreExceptions !== true) { - throw new Error(`Exception while processing ${JSON.stringify(interaction)}:\n${result.result}`); + throw new Error(`Exception while processing ${JSON.stringify(interaction)}:\n${result.errorMessage}`); } validity[interactionTx.id] = result.type === 'ok';