Files
warp/src/contract/HandlerBasedContract.ts
2022-04-07 16:02:44 +02:00

686 lines
23 KiB
TypeScript

import {
ArTransfer,
ArWallet,
ArweaveWrapper,
Benchmark,
BenchmarkStats,
Contract,
ContractCallStack,
ContractInteraction,
createDummyTx,
createTx,
CurrentTx,
DefaultEvaluationOptions,
emptyTransfer,
EvalStateResult,
EvaluationOptions,
Evolve,
ExecutionContext,
GQLEdgeInterface,
GQLNodeInterface,
HandlerApi,
InnerWritesEvaluator,
InteractionCall,
InteractionData,
InteractionResult,
LoggerFactory,
sleep,
SmartWeave,
SmartWeaveTags,
SourceType,
Tags
} from '@smartweave';
import { TransactionStatusResponse } from 'arweave/node/transactions';
import { NetworkInfoInterface } from 'arweave/node/network';
import stringify from 'safe-stable-stringify';
import * as crypto from 'crypto';
/**
* An implementation of {@link Contract} that is backwards compatible with current style
* of writing SW contracts (ie. using the "handle" function).
*
* It requires {@link ExecutorFactory} that is using {@link HandlerApi} generic type.
*/
export class HandlerBasedContract<State> implements Contract<State> {
private readonly logger = LoggerFactory.INST.create('HandlerBasedContract');
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?: Partial<NetworkInfoInterface> = null;
private _rootBlockHeight: number = null;
private readonly _innerWritesEvaluator = new InnerWritesEvaluator();
private readonly _callDepth: number;
private _benchmarkStats: BenchmarkStats = null;
private readonly _arweaveWrapper: ArweaveWrapper;
/**
* wallet connected to this contract
*/
protected wallet?: ArWallet;
constructor(
private readonly _contractTxId: string,
protected readonly smartweave: SmartWeave,
private readonly _parentContract: Contract = null,
private readonly _callingInteraction: GQLNodeInterface = null
) {
this.waitForConfirmation = this.waitForConfirmation.bind(this);
this._arweaveWrapper = new ArweaveWrapper(smartweave.arweave);
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);
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) {
throw Error('Calling contract should have the network info already set!');
}
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._callDepth = 0;
this._callStack = new ContractCallStack(_contractTxId, 0);
}
}
async readState(blockHeight?: number, currentTx?: CurrentTx[]): Promise<EvalStateResult<State>> {
return this.readStateSequencer(blockHeight, undefined, currentTx);
}
async readStateSequencer(
blockHeight: number,
upToTransactionId: string,
currentTx?: CurrentTx[]
): Promise<EvalStateResult<State>> {
this.logger.info('Read state EN for', {
contractTxId: this._contractTxId,
currentTx
});
const initBenchmark = Benchmark.measure();
this.maybeResetRootContract(blockHeight);
const { stateEvaluator } = this.smartweave;
const executionContext = await this.createExecutionContext(
this._contractTxId,
blockHeight,
false,
upToTransactionId
);
this.logger.info('Execution Context', {
blockHeight: executionContext.blockHeight,
srcTxId: executionContext.contractDefinition?.srcTxId,
missingInteractions: executionContext.sortedInteractions.length,
cachedStateHeight: executionContext.cachedState?.cachedHeight,
upToTransactionId
});
initBenchmark.stop();
const stateBenchmark = Benchmark.measure();
const result = await stateEvaluator.eval(executionContext, currentTx || []);
stateBenchmark.stop();
const total = (initBenchmark.elapsed(true) as number) + (stateBenchmark.elapsed(true) as number);
this._benchmarkStats = {
gatewayCommunication: initBenchmark.elapsed(true) as number,
stateEvaluation: stateBenchmark.elapsed(true) as number,
total
};
this.logger.info('Benchmark', {
'Gateway communication ': initBenchmark.elapsed(),
'Contract evaluation ': stateBenchmark.elapsed(),
'Total: ': `${total.toFixed(0)}ms`
});
return result as EvalStateResult<State>;
}
async viewState<Input, View>(
input: Input,
blockHeight?: number,
tags: Tags = [],
transfer: ArTransfer = emptyTransfer
): Promise<InteractionResult<State, View>> {
this.logger.info('View state for', this._contractTxId);
return await this.callContract<Input, View>(input, undefined, blockHeight, tags, transfer);
}
async viewStateForTx<Input, View>(
input: Input,
interactionTx: GQLNodeInterface
): Promise<InteractionResult<State, View>> {
this.logger.info(`View state for ${this._contractTxId}`, interactionTx);
return await this.callContractForTx<Input, View>(input, interactionTx);
}
async dryWrite<Input>(
input: Input,
caller?: string,
tags?: Tags,
transfer?: ArTransfer
): Promise<InteractionResult<State, unknown>> {
this.logger.info('Dry-write for', this._contractTxId);
return await this.callContract<Input>(input, caller, undefined, tags, transfer);
}
async dryWriteFromTx<Input>(
input: Input,
transaction: GQLNodeInterface,
currentTx?: CurrentTx[]
): Promise<InteractionResult<State, unknown>> {
this.logger.info(`Dry-write from transaction ${transaction.id} for ${this._contractTxId}`);
return await this.callContractForTx<Input>(input, transaction, currentTx || []);
}
async writeInteraction<Input>(
input: Input,
tags: Tags = [],
transfer: ArTransfer = emptyTransfer,
strict = false
): Promise<string | null> {
this.logger.info('Write interaction input', input);
if (!this.wallet) {
throw new Error("Wallet not connected. Use 'connect' method first.");
}
const { arweave } = this.smartweave;
const interactionTx = await this.createInteraction(input, tags, transfer, strict);
const response = await arweave.transactions.post(interactionTx);
if (response.status !== 200) {
this.logger.error('Error while posting transaction', response);
return null;
}
if (this._evaluationOptions.waitForConfirmation) {
this.logger.info('Waiting for confirmation of', interactionTx.id);
const benchmark = Benchmark.measure();
await this.waitForConfirmation(interactionTx.id);
this.logger.info('Transaction confirmed after', benchmark.elapsed());
}
return interactionTx.id;
}
async bundleInteraction<Input>(
input: Input,
tags: Tags = [],
transfer: ArTransfer = emptyTransfer,
strict = false
): Promise<any | null> {
this.logger.info('Bundle interaction input', input);
if (!this.wallet) {
throw new Error("Wallet not connected. Use 'connect' method first.");
}
const interactionTx = await this.createInteraction(input, tags, transfer, strict);
const response = await fetch(`${this._evaluationOptions.sequencerAddress}gateway/sequencer/register`, {
method: 'POST',
body: JSON.stringify(interactionTx),
headers: {
'Accept-Encoding': 'gzip, deflate, br',
'Content-Type': 'application/json',
Accept: 'application/json'
}
})
.then((res) => {
this.logger.debug(res);
return res.ok ? res.json() : Promise.reject(res);
})
.catch((error) => {
this.logger.error(error);
if (error.body?.message) {
this.logger.error(error.body.message);
}
throw new Error(`Unable to bundle interaction: ${JSON.stringify(error)}`);
});
return {
bundlrResponse: response,
originalTxId: interactionTx.id
};
}
private async createInteraction<Input>(
input: Input,
tags: { name: string; value: string }[],
transfer: ArTransfer,
strict: boolean
) {
if (this._evaluationOptions.internalWrites) {
// Call contract and verify if there are any internal writes:
// 1. Evaluate current contract state
// 2. Apply input as "dry-run" transaction
// 3. Verify the callStack and search for any "internalWrites" transactions
// 4. For each found "internalWrite" transaction - generate additional tag:
// {name: 'InternalWrite', value: callingContractTxId}
const handlerResult = await this.callContract(input, undefined, undefined, tags, transfer);
if (strict && handlerResult.type !== 'ok') {
throw Error(`Cannot create interaction: ${handlerResult.errorMessage}`);
}
const callStack: ContractCallStack = this.getCallStack();
const innerWrites = this._innerWritesEvaluator.eval(callStack);
this.logger.debug('Input', input);
this.logger.debug('Callstack', callStack.print());
innerWrites.forEach((contractTxId) => {
tags.push({
name: SmartWeaveTags.INTERACT_WRITE,
value: contractTxId
});
});
this.logger.debug('Tags with inner calls', tags);
} else {
if (strict) {
const handlerResult = await this.callContract(input, undefined, undefined, tags, transfer);
if (handlerResult.type !== 'ok') {
throw Error(`Cannot create interaction: ${handlerResult.errorMessage}`);
}
}
}
const interactionTx = await createTx(
this.smartweave.arweave,
this.wallet,
this._contractTxId,
input,
tags,
transfer.target,
transfer.winstonQty
);
return interactionTx;
}
txId(): string {
return this._contractTxId;
}
getCallStack(): ContractCallStack {
return this._callStack;
}
getNetworkInfo(): Partial<NetworkInfoInterface> {
return this._networkInfo;
}
connect(wallet: ArWallet): Contract<State> {
this.wallet = wallet;
return this;
}
setEvaluationOptions(options: Partial<EvaluationOptions>): Contract<State> {
this._evaluationOptions = {
...this._evaluationOptions,
...options
};
return this;
}
getRootBlockHeight(): number {
return this._rootBlockHeight;
}
private async waitForConfirmation(transactionId: string): Promise<TransactionStatusResponse> {
const { arweave } = this.smartweave;
const status = await arweave.transactions.getStatus(transactionId);
if (status.confirmed === null) {
this.logger.info(`Transaction ${transactionId} not yet confirmed. Waiting another 20 seconds before next check.`);
await sleep(20000);
await this.waitForConfirmation(transactionId);
} else {
this.logger.info(`Transaction ${transactionId} confirmed`, status);
return status;
}
}
private async createExecutionContext(
contractTxId: string,
blockHeight?: number,
forceDefinitionLoad = false,
upToTransactionId: string = undefined
): Promise<ExecutionContext<State, HandlerApi<State>>> {
const { definitionLoader, interactionsLoader, interactionsSorter, executorFactory, stateEvaluator } =
this.smartweave;
let currentNetworkInfo;
const benchmark = Benchmark.measure();
// if this is a "root" call (ie. original call from SmartWeave's client)
if (this._parentContract == null) {
if (blockHeight) {
this._networkInfo = {
height: blockHeight
};
} else {
this.logger.debug('Reading network info for root call');
currentNetworkInfo = await this._arweaveWrapper.info();
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');
// note: the whole execution tree should use the same network info!
// this requirement was not fulfilled in the "v1" SDK - each subsequent
// 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._parentContract as HandlerBasedContract<State>)._networkInfo;
}
if (blockHeight == null) {
blockHeight = currentNetworkInfo.height;
}
this.logger.debug('network info', benchmark.elapsed());
benchmark.reset();
const cachedState = await stateEvaluator.latestAvailableState<State>(contractTxId, blockHeight);
let cachedBlockHeight = -1;
if (cachedState != null) {
cachedBlockHeight = cachedState.cachedHeight;
}
this.logger.debug('cache lookup', benchmark.elapsed());
benchmark.reset();
const evolvedSrcTxId = Evolve.evolvedSrcTxId(cachedState?.cachedValue?.state);
let contractDefinition,
interactions: GQLEdgeInterface[] = [],
sortedInteractions: GQLEdgeInterface[] = [],
handler;
if (cachedBlockHeight != blockHeight) {
[contractDefinition, interactions] = await Promise.all([
definitionLoader.load<State>(contractTxId, evolvedSrcTxId),
// note: "eagerly" loading all of the interactions up to the originally requested block height
// (instead of the blockHeight requested for this specific read state call).
// as dumb as it may seem - this in fact significantly speeds up the processing
// - because the InteractionsLoader (usually CacheableContractInteractionsLoader)
// doesn't have to download missing interactions during the contract execution
// (eg. if contract is calling different contracts on different block heights).
// This basically limits the amount of interactions with Arweave GraphQL endpoint -
// each such interaction takes at least ~500ms.
interactionsLoader.load(
contractTxId,
cachedBlockHeight + 1,
this._rootBlockHeight || this._networkInfo.height,
this._evaluationOptions,
upToTransactionId
)
]);
this.logger.debug('contract and interactions load', benchmark.elapsed());
sortedInteractions = await interactionsSorter.sort(interactions);
this.logger.trace('Sorted interactions', sortedInteractions);
handler = (await executorFactory.create(contractDefinition, this._evaluationOptions)) as HandlerApi<State>;
} else {
this.logger.debug('State fully cached, not loading interactions.');
if (forceDefinitionLoad || evolvedSrcTxId) {
contractDefinition = await definitionLoader.load<State>(contractTxId, evolvedSrcTxId);
handler = (await executorFactory.create(contractDefinition, this._evaluationOptions)) as HandlerApi<State>;
}
}
const containsInteractionsFromSequencer = interactions.some((i) => i.node.source == SourceType.REDSTONE_SEQUENCER);
this.logger.debug('containsInteractionsFromSequencer', containsInteractionsFromSequencer);
return {
contractDefinition,
blockHeight,
sortedInteractions,
handler,
smartweave: this.smartweave,
contract: this,
evaluationOptions: this._evaluationOptions,
currentNetworkInfo,
cachedState,
containsInteractionsFromSequencer,
upToTransactionId
};
}
private async createExecutionContextFromTx(
contractTxId: string,
transaction: GQLNodeInterface
): Promise<ExecutionContext<State, HandlerApi<State>>> {
const benchmark = Benchmark.measure();
const { definitionLoader, interactionsLoader, interactionsSorter, executorFactory, stateEvaluator } =
this.smartweave;
const blockHeight = transaction.block.height;
const caller = transaction.owner.address;
const cachedState = await stateEvaluator.latestAvailableState<State>(contractTxId, blockHeight);
let cachedBlockHeight = -1;
if (cachedState != null) {
cachedBlockHeight = cachedState.cachedHeight;
}
let contractDefinition,
interactions = [],
sortedInteractions = [];
if (cachedBlockHeight != blockHeight) {
[contractDefinition, interactions] = await Promise.all([
definitionLoader.load<State>(contractTxId),
await interactionsLoader.load(contractTxId, 0, blockHeight, this._evaluationOptions)
]);
sortedInteractions = await interactionsSorter.sort(interactions);
} else {
this.logger.debug('State fully cached, not loading interactions.');
contractDefinition = await definitionLoader.load<State>(contractTxId);
}
const handler = (await executorFactory.create(contractDefinition, this._evaluationOptions)) as HandlerApi<State>;
this.logger.debug('Creating execution context from tx:', benchmark.elapsed());
const containsInteractionsFromSequencer = interactions.some((i) => i.node.source == SourceType.REDSTONE_SEQUENCER);
return {
contractDefinition,
blockHeight,
sortedInteractions,
handler,
smartweave: this.smartweave,
contract: this,
evaluationOptions: this._evaluationOptions,
caller,
cachedState,
containsInteractionsFromSequencer
};
}
private maybeResetRootContract(blockHeight?: number) {
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(), 0);
this._rootBlockHeight = blockHeight;
}
}
private async callContract<Input, View = unknown>(
input: Input,
caller?: string,
blockHeight?: number,
tags: Tags = [],
transfer: ArTransfer = emptyTransfer
): Promise<InteractionResult<State, View>> {
this.logger.info('Call contract input', input);
this.maybeResetRootContract();
if (!this.wallet) {
this.logger.warn('Wallet not set.');
}
const { arweave, stateEvaluator } = this.smartweave;
// create execution context
let executionContext = await this.createExecutionContext(this._contractTxId, blockHeight, true);
// add block data to execution context
if (!executionContext.currentBlockData) {
const currentBlockData = executionContext.currentNetworkInfo?.current
? // trying to optimise calls to arweave as much as possible...
await arweave.blocks.get(executionContext.currentNetworkInfo.current)
: await arweave.blocks.getCurrent();
executionContext = {
...executionContext,
currentBlockData
};
}
// add caller info to execution context
const effectiveCaller = caller || (this.wallet ? await arweave.wallets.jwkToAddress(this.wallet) : '');
executionContext = {
...executionContext,
caller: effectiveCaller
};
// eval current state
const evalStateResult = await stateEvaluator.eval<State>(executionContext, []);
// create interaction transaction
const interaction: ContractInteraction<Input> = {
input,
caller: executionContext.caller
};
this.logger.debug('interaction', interaction);
const tx = await createTx(
arweave,
this.wallet,
this._contractTxId,
input,
tags,
transfer.target,
transfer.winstonQty
);
const dummyTx = createDummyTx(tx, executionContext.caller, executionContext.currentBlockData);
const handleResult = await this.evalInteraction<Input, View>(
{
interaction,
interactionTx: dummyTx,
currentTx: []
},
executionContext,
evalStateResult
);
if (handleResult.type !== 'ok') {
this.logger.fatal('Error while interacting with contract', {
type: handleResult.type,
error: handleResult.errorMessage
});
}
return handleResult;
}
private async callContractForTx<Input, View = unknown>(
input: Input,
interactionTx: GQLNodeInterface,
currentTx?: CurrentTx[]
): Promise<InteractionResult<State, View>> {
this.maybeResetRootContract();
const executionContext = await this.createExecutionContextFromTx(this._contractTxId, interactionTx);
const evalStateResult = await this.smartweave.stateEvaluator.eval<State>(executionContext, currentTx);
this.logger.debug('callContractForTx - evalStateResult', {
result: evalStateResult.state,
txId: this._contractTxId
});
const interaction: ContractInteraction<Input> = {
input,
caller: this._parentContract.txId()
};
const interactionData: InteractionData<Input> = {
interaction,
interactionTx,
currentTx
};
return await this.evalInteraction(interactionData, executionContext, evalStateResult);
}
private async evalInteraction<Input, View = unknown>(
interactionData: InteractionData<Input>,
executionContext: ExecutionContext<State, HandlerApi<State>>,
evalStateResult: EvalStateResult<State>
) {
const interactionCall: InteractionCall = this.getCallStack().addInteractionData(interactionData);
const benchmark = Benchmark.measure();
const result = await executionContext.handler.handle<Input, View>(
executionContext,
evalStateResult,
interactionData
);
interactionCall.update({
cacheHit: false,
intermediaryCacheHit: false,
outputState: this._evaluationOptions.stackTrace.saveState ? result.state : undefined,
executionTime: benchmark.elapsed(true) as number,
valid: result.type === 'ok',
errorMessage: result.errorMessage,
gasUsed: result.gasUsed
});
return result;
}
parent(): Contract | null {
return this._parentContract;
}
callDepth(): number {
return this._callDepth;
}
evaluationOptions(): EvaluationOptions {
return this._evaluationOptions;
}
lastReadStateStats(): BenchmarkStats {
return this._benchmarkStats;
}
stateHash(state: State): string {
const jsonState = stringify(state);
// note: cannot reuse:
// "The Hash object can not be used again after hash.digest() method has been called.
// Multiple calls will cause an error to be thrown."
const hash = crypto.createHash('sha256');
hash.update(jsonState);
return hash.digest('hex');
}
}