diff --git a/docs/BUNDLED_CONTRACT.md b/docs/BUNDLED_CONTRACT.md index d507022..b995782 100644 --- a/docs/BUNDLED_CONTRACT.md +++ b/docs/BUNDLED_CONTRACT.md @@ -59,7 +59,7 @@ Transaction which is sent to Bundlr, consists of: **NOTE** The original transaction is not modified in any way - this is to preserve the original signature! -2. After receiving proper response and recipes from Bundlr, the Warp gateway indexes the contract +2. After receiving proper response and receipt from Bundlr, the Warp gateway indexes the contract transactions data internally - to make them instantly available. 3. Finally, the Warp gateway returns an object as a `response` - that consists of fields: diff --git a/docs/BUNDLED_INTERACTION.md b/docs/BUNDLED_INTERACTION.md index d115b47..83a4789 100644 --- a/docs/BUNDLED_INTERACTION.md +++ b/docs/BUNDLED_INTERACTION.md @@ -130,7 +130,7 @@ Using the `sort_key`, `vrf-proof` and `vrf-pubkey`, the client can always verify **NOTE** The original transaction is not modified in any way - this is to preserve the original signature! -After receiving proper response and recipes from Bundlr, the Warp gateway indexes the contract interaction +After receiving proper response and receipt from Bundlr, the Warp gateway indexes the contract interaction internally - to make it instantly available. #### 4. Finally, the Warp gateway returns the response from the Bundlr to the client. diff --git a/src/__tests__/integration/data/aftr/sampleContractSrc.js b/src/__tests__/integration/data/aftr/sampleContractSrc.js index 4d35211..59e205b 100644 --- a/src/__tests__/integration/data/aftr/sampleContractSrc.js +++ b/src/__tests__/integration/data/aftr/sampleContractSrc.js @@ -74,17 +74,13 @@ async function handle(state, action) { if (input.lockLength) { lockLength = input.lockLength; } - console.log('====== AFTR CONTRACT deposit FN - calling claim on: ', input.tokenId); const transferResult = await SmartWeave.contracts.write(input.tokenId, { function: "claim", txID: input.txID, qty: input.qty }); - console.log('====== AFTR CONTRACT deposit FN - calling claim result ', transferResult); - console.log('====== AFTR CONTRACT deposit FN - PST state', transferResult.state); const tokenInfo = getTokenInfo(transferResult.state); - console.log('Token info', tokenInfo); const txObj = { txID: input.txID, tokenId: input.tokenId, @@ -128,7 +124,6 @@ async function handle(state, action) { }); } if (input.function === "claim") { - console.log('====== Claim function BEGIN ==='); const txID = input.txID; const qty = input.qty; if (!state.claimable.length) { @@ -161,8 +156,6 @@ async function handle(state, action) { balances[caller] += obj.qty; state.claimable.splice(index, 1); state.claims.push(txID); - - console.log('====== Claim function END ==='); } diff --git a/src/__tests__/integration/data/aftr/sampleContractSrc_broken.js b/src/__tests__/integration/data/aftr/sampleContractSrc_broken.js new file mode 100644 index 0000000..d577d0a --- /dev/null +++ b/src/__tests__/integration/data/aftr/sampleContractSrc_broken.js @@ -0,0 +1,196 @@ +async function handle(state, action) { + const balances = state.balances; + const input = action.input; + const caller = action.caller; + let target = ""; + let balance = 0; + + if (input.function === "balance") { + target = isArweaveAddress(input.target || caller); + if (typeof target !== "string") { + throw new ContractError("Must specificy target to get balance for."); + } + balance = 0; + if (target in balances) { + balance = balances[target]; + } + } + + if (input.function === "transfer") { + const target2 = input.target; + const qty = input.qty; + const callerAddress = isArweaveAddress(caller); + const targetAddress = isArweaveAddress(target2); + if (!Number.isInteger(qty)) { + throw new ContractError('Invalid value for "qty". Must be an integer.'); + } + if (!targetAddress) { + throw new ContractError("No target specified."); + } + if (qty <= 0 || callerAddress === targetAddress) { + throw new ContractError("Invalid token transfer."); + } + if (!(callerAddress in balances)) { + throw new ContractError("Caller doesn't own a balance in the Vehicle."); + } + if (balances[callerAddress] < qty) { + throw new ContractError(`Caller balance not high enough to send ${qty} token(s)!`); + } + if (SmartWeave.contract.id === target2) { + throw new ContractError("A vehicle token cannot be transferred to itself because it would add itself the balances object of the vehicle, thus changing the membership of the vehicle without a vote."); + } + if (state.ownership === "single" && callerAddress === state.creator && balances[callerAddress] - qty <= 0) { + throw new ContractError("Invalid transfer because the creator's balance would be 0."); + } + balances[callerAddress] -= qty; + if (targetAddress in balances) { + balances[targetAddress] += qty; + } else { + balances[targetAddress] = qty; + } + } + if (input.function === "mint") { + if (!input.qty) { + throw new ContractError("Missing qty."); + } + if (!(caller in state.balances)) { + balances[caller] = input.qty; + } + } + if (input.function === "deposit") { + if (!input.txID) { + throw new ContractError("The transaction is not valid. Tokens were not transferred to the vehicle."); + } + if (!input.tokenId) { + throw new ContractError("No token supplied. Tokens were not transferred to the vehicle."); + } + if (input.tokenId === SmartWeave.contract.id) { + throw new ContractError("Deposit not allowed because you can't deposit an asset of itself."); + } + if (!input.qty || typeof +input.qty !== "number" || +input.qty <= 0) { + throw new ContractError("Qty is invalid."); + } + let lockLength = 0; + if (input.lockLength) { + lockLength = input.lockLength; + } + + await SmartWeave.contracts.write(input.tokenId, { + function: "claim", + txID: input.txID, + qty: input.qty + }); + + // note: getTokenInfo underneath makes a readContractState on input.tokenId + // the SDK should now throw in such case (i.e. making a read on a contract on which + // we've just made write). + const tokenInfo = await getTokenInfo(input.tokenId); + const txObj = { + txID: input.txID, + tokenId: input.tokenId, + source: caller, + balance: input.qty, + start: SmartWeave.block.height, + name: tokenInfo.name, + ticker: tokenInfo.ticker, + logo: tokenInfo.logo, + lockLength + }; + if (!state.tokens) { + state["tokens"] = []; + } + state.tokens.push(txObj); + } + if (input.function === "allow") { + target = input.target; + const quantity = input.qty; + if (!Number.isInteger(quantity) || quantity === void 0) { + throw new ContractError("Invalid value for quantity. Must be an integer."); + } + if (!target) { + throw new ContractError("No target specified."); + } + if (quantity <= 0 || caller === target) { + throw new ContractError("Invalid token transfer."); + } + if (balances[caller] < quantity) { + throw new ContractError("Caller balance not high enough to make claimable " + quantity + " token(s)."); + } + balances[caller] -= quantity; + if (balances[caller] === null || balances[caller] === void 0) { + balances[caller] = 0; + } + state.claimable.push({ + from: caller, + to: target, + qty: quantity, + txID: SmartWeave.transaction.id + }); + } + if (input.function === "claim") { + const txID = input.txID; + const qty = input.qty; + if (!state.claimable.length) { + throw new ContractError("Contract has no claims available."); + } + let obj, index; + for (let i = 0; i < state.claimable.length; i++) { + if (state.claimable[i].txID === txID) { + index = i; + obj = state.claimable[i]; + } + } + if (obj === void 0) { + throw new ContractError("Unable to find claim."); + } + if (obj.to !== caller) { + throw new ContractError("Claim not addressed to caller."); + } + if (obj.qty !== qty) { + throw new ContractError("Claiming incorrect quantity of tokens."); + } + for (let i = 0; i < state.claims.length; i++) { + if (state.claims[i] === txID) { + throw new ContractError("This claim has already been made."); + } + } + if (!balances[caller]) { + balances[caller] = 0; + } + balances[caller] += obj.qty; + state.claimable.splice(index, 1); + state.claims.push(txID); + } + + + if (input.function === "balance") { + let vaultBal = 0; + try { + for (let bal of state.vault[caller]) { + vaultBal += bal.balance; + } + } catch (e) { + } + return {result: {target, balance, vaultBal}}; + } else { + return {state}; + } +} + +function isArweaveAddress(addy) { + const address = addy.toString().trim(); + if (!/[a-z0-9_-]{43}/i.test(address)) { + throw new ContractError("Invalid Arweave address."); + } + return address; +} + +async function getTokenInfo(contractId) { + const assetState = await SmartWeave.contracts.readContractState(contractId); + const settings = new Map(assetState.settings); + return { + name: currentTokenState.name, + ticker: currentTokenState.ticker, + logo: settings.get("communityLogo") + }; +} diff --git a/src/__tests__/integration/data/example-contract.js b/src/__tests__/integration/data/example-contract.js index 12438ed..e39a497 100644 --- a/src/__tests__/integration/data/example-contract.js +++ b/src/__tests__/integration/data/example-contract.js @@ -49,7 +49,6 @@ export async function handle(state, action) { return { result: value }; } if (action.input.function === 'justThrow') { - console.log('called justThrow'); throw new ContractError('Error from justThrow function'); } } diff --git a/src/__tests__/integration/data/writing-contract.js b/src/__tests__/integration/data/writing-contract.js index b4c0ce4..b201bf1 100644 --- a/src/__tests__/integration/data/writing-contract.js +++ b/src/__tests__/integration/data/writing-contract.js @@ -9,7 +9,6 @@ export async function handle(state, action) { } if (action.input.function === 'writeContractAutoThrow') { - console.log('before calling justThrow'); await SmartWeave.contracts.write(action.input.contractId, { function: 'justThrow', }); @@ -17,7 +16,6 @@ export async function handle(state, action) { state.errorCounter = 0; } state.errorCounter++; - console.log('after calling justThrow', state.errorCounter); return { state }; } if (action.input.function === 'writeContractForceAutoThrow') { diff --git a/src/__tests__/integration/internal-writes/internal-write-aftr.test.ts b/src/__tests__/integration/internal-writes/internal-write-aftr.test.ts index a87b358..4d9c45b 100644 --- a/src/__tests__/integration/internal-writes/internal-write-aftr.test.ts +++ b/src/__tests__/integration/internal-writes/internal-write-aftr.test.ts @@ -281,7 +281,74 @@ describe('Testing internal writes', () => { ['communityLogo', ''] ] }); + }); + }); + + describe('AFTR test case - with an illegal read state after an internal write', () => { + it('should throw an Error if contract makes readContractState after write on the same contract', async () => { + const newWarpInstance = WarpFactory.forLocal(port); + const { jwk: wallet2 } = await newWarpInstance.testing.generateWallet(); + + const aftrBrokenContractSrc = fs.readFileSync(path.join(__dirname, '../data/aftr/sampleContractSrc_broken.js'), 'utf8'); + + const aftrBrokenContractInitialState = fs.readFileSync(path.join(__dirname, '../data/aftr/sampleContractInitState.json'), 'utf8'); + const pst2InitState = fs.readFileSync(path.join(__dirname, '../data/aftr/pstInitState.json'), 'utf8'); + + const {contractTxId: aftrBrokenTxId, srcTxId: brokenSrcTxId} = await newWarpInstance.createContract.deploy({ + wallet: wallet2, + initState: aftrBrokenContractInitialState, + src: aftrBrokenContractSrc + }); + + const {contractTxId: pst2TxId} = await newWarpInstance.createContract.deployFromSourceTx({ + wallet: wallet2, + initState: pst2InitState, + srcTxId: brokenSrcTxId + }); + + const aftrBroken = newWarpInstance + .contract(aftrBrokenTxId) + .setEvaluationOptions({ + internalWrites: true + }) + .connect(wallet2); + + const pst2 = newWarpInstance + .contract(pst2TxId) + .setEvaluationOptions({ + internalWrites: true + }) + .connect(wallet2); + + await mineBlock(newWarpInstance); + + // (o) mint 10000 tokens in pst contract + await pst2.writeInteraction({ + function: "mint", + qty: 10000 + }); + + const transferQty = 1; + // (o) set allowance on pst contract for aftr contract + const {originalTxId} = await pst2.writeInteraction({ + function: "allow", + target: aftrBrokenTxId, + qty: transferQty, + }); + + // (o) make a deposit transaction on the AFTR contract + // note: this transaction makes internalWrite on PST contract + // and then makes a readContractState on a PST contract + // - such operation is not allowed. + await expect(aftrBroken.writeInteraction({ + function: "deposit", + tokenId: pst2TxId, + qty: transferQty, + txID: originalTxId, + }, { strict: true })).rejects.toThrowError( + /Calling a readContractState after performing an inner write is wrong - instead use a state from the result of an internal write./ + ); }); }); }); diff --git a/src/__tests__/integration/internal-writes/internal-write-callee.test.ts b/src/__tests__/integration/internal-writes/internal-write-callee.test.ts index ad7c42a..b27df4d 100644 --- a/src/__tests__/integration/internal-writes/internal-write-callee.test.ts +++ b/src/__tests__/integration/internal-writes/internal-write-callee.test.ts @@ -279,7 +279,7 @@ describe('Testing internal writes', () => { }); }); - describe('with internal writes throwing exceptions', () => { + fdescribe('with internal writes throwing exceptions', () => { beforeAll(async () => { await deployContracts(); }); diff --git a/src/contract/Contract.ts b/src/contract/Contract.ts index 51ac97b..a1f9ac2 100644 --- a/src/contract/Contract.ts +++ b/src/contract/Contract.ts @@ -63,6 +63,10 @@ export interface EvolveState { evolve: string; } +export type InnerCallType = 'read' | 'view' | 'write'; + +export type InnerCallData = { callingInteraction: GQLNodeInterface; callType: InnerCallType }; + /** * A base interface to be implemented by SmartWeave Contracts clients * - contains "low-level" methods that allow to interact with any contract @@ -91,8 +95,7 @@ export interface Contract extends Source { setEvaluationOptions(options: Partial): Contract; /** - * Returns state of the contract at required blockHeight. - * Similar to {@link readContract} from the current version. + * Returns state of the contract at required sortKey or blockHeight. * * @param sortKeyOrBlockHeight - either a sortKey or block height at which the contract should be read * diff --git a/src/contract/HandlerBasedContract.ts b/src/contract/HandlerBasedContract.ts index 3a937da..7047414 100644 --- a/src/contract/HandlerBasedContract.ts +++ b/src/contract/HandlerBasedContract.ts @@ -29,7 +29,8 @@ import { SigningFunction, CurrentTx, WriteInteractionOptions, - WriteInteractionResponse + WriteInteractionResponse, + InnerCallData } from './Contract'; import { Tags, ArTransfer, emptyTransfer, ArWallet } from './deploy/CreateContract'; import { SourceData, SourceImpl } from './deploy/impl/SourceImpl'; @@ -62,7 +63,7 @@ export class HandlerBasedContract implements Contract { private readonly _contractTxId: string, protected readonly warp: Warp, private readonly _parentContract: Contract = null, - private readonly _callingInteraction: GQLNodeInterface = null + private readonly _innerCallData: InnerCallData = null ) { this.waitForConfirmation = this.waitForConfirmation.bind(this); this._arweaveWrapper = new ArweaveWrapper(warp.arweave); @@ -70,7 +71,9 @@ export class HandlerBasedContract implements Contract { if (_parentContract != null) { this._evaluationOptions = _parentContract.evaluationOptions(); this._callDepth = _parentContract.callDepth() + 1; - const callingInteraction: InteractionCall = _parentContract.getCallStack().getInteraction(_callingInteraction.id); + const callingInteraction: InteractionCall = _parentContract + .getCallStack() + .getInteraction(_innerCallData.callingInteraction.id); if (this._callDepth > this._evaluationOptions.maxCallDepth) { throw Error( @@ -79,14 +82,30 @@ export class HandlerBasedContract implements Contract { )}` ); } - this.logger.debug('Calling interaction', { id: _callingInteraction.id, sortKey: _callingInteraction.sortKey }); - const callStack = new ContractCallStack(_contractTxId, this._callDepth); + this.logger.debug('Calling interaction', { + id: _innerCallData.callingInteraction.id, + sortKey: _innerCallData.callingInteraction.sortKey, + type: _innerCallData.callType + }); + + // if you're reading a state of the contract, on which you've just made a write - you're doing it wrong. + // the current state of the callee contract is always in the result of an internal write. + // following is a protection against naughty developers who might be doing such crazy things ;-) + if ( + callingInteraction.interactionInput?.foreignContractCalls[_contractTxId]?.innerCallType === 'write' && + _innerCallData.callType === 'read' + ) { + throw new Error( + 'Calling a readContractState after performing an inner write is wrong - instead use a state from the result of an internal write.' + ); + } + + const callStack = new ContractCallStack(_contractTxId, this._callDepth, _innerCallData?.callType); callingInteraction.interactionInput.foreignContractCalls[_contractTxId] = callStack; this._callStack = callStack; this._rootSortKey = _parentContract.rootSortKey; - console.log('====CHILD constructor, parent callstack: ', _parentContract.getCallStack().print()); - + // console.log('==== CHILD constructor, parent callstack: ', _parentContract.getCallStack().print()); } else { this._callDepth = 0; this._callStack = new ContractCallStack(_contractTxId, 0); @@ -318,7 +337,7 @@ export class HandlerBasedContract implements Contract { const handlerResult = await this.callContract(input, undefined, undefined, tags, transfer, strict); if ((input as any).function === 'deposit') { - console.log('====== handlerResult ======', handlerResult); + // console.log('====== handlerResult ======', handlerResult); } if (strict && handlerResult.type !== 'ok') { throw Error(`Cannot create interaction: ${handlerResult.errorMessage}`); @@ -329,8 +348,8 @@ export class HandlerBasedContract implements Contract { this.logger.debug('Callstack', callStack.print()); if ((input as any).function === 'deposit') { - console.log('====== Call stack ======', callStack.print()); - console.log('====== Inner Writes ======', innerWrites); + // console.log('====== Call stack ======', callStack.print()); + // console.log('====== Inner Writes ======', innerWrites); } innerWrites.forEach((contractTxId) => { @@ -624,13 +643,13 @@ export class HandlerBasedContract implements Contract { // there could haven been some earlier, non-cached interactions - which will be added // after eval in line 619. We need to clear them, as it is only the currently // being added interaction that we're interested in. - if (this._parentContract) { + /*if (this._parentContract) { console.log('======== CLEARING CALL STACK'); const callStack = new ContractCallStack(this.txId(), this._callDepth); const callingInteraction = this._parentContract.getCallStack().getInteraction(this._callingInteraction.id); callingInteraction.interactionInput.foreignContractCalls[this.txId()] = callStack; this._callStack = callStack; - } + }*/ this.logger.debug('callContractForTx - evalStateResult', { result: evalStateResult.cachedValue.state, txId: this._contractTxId @@ -647,7 +666,7 @@ export class HandlerBasedContract implements Contract { currentTx }; - console.log('====== evalInteraction'); + // console.log('====== evalInteraction'); const result = await this.evalInteraction( interactionData, executionContext, @@ -664,15 +683,15 @@ export class HandlerBasedContract implements Contract { executionContext: ExecutionContext>, evalStateResult: EvalStateResult ) { - console.log('====== addInteractionData to callStack', interactionData.interaction.input); - console.log('====== addInteractionData to callStack - callstack', this.getCallStack().print()); - console.log('====== addInteractionData to callStack - parent callstack', this.parent()?.getCallStack().print()); + // console.log('====== addInteractionData to callStack', interactionData.interaction.input); + // console.log('====== addInteractionData to callStack - callstack', this.getCallStack().print()); + // console.log('====== addInteractionData to callStack - parent callstack', this.parent()?.getCallStack().print()); const interactionCall: InteractionCall = this.getCallStack().addInteractionData(interactionData); - console.log('====== AFTER ADDING ======'); - console.log('====== addInteractionData to callStack - callstack', this.getCallStack().print()); - console.log('====== addInteractionData to callStack - parent callstack', this.parent()?.getCallStack().print()); + // console.log('====== AFTER ADDING ======'); + // console.log('====== addInteractionData to callStack - callstack', this.getCallStack().print()); + // console.log('====== addInteractionData to callStack - parent callstack', this.parent()?.getCallStack().print()); const benchmark = Benchmark.measure(); const result = await executionContext.handler.handle( @@ -681,6 +700,8 @@ export class HandlerBasedContract implements Contract { interactionData ); + // console.log('RESULT', result); + interactionCall.update({ cacheHit: false, outputState: this._evaluationOptions.stackTrace.saveState ? result.state : undefined, @@ -690,8 +711,8 @@ export class HandlerBasedContract implements Contract { gasUsed: result.gasUsed }); - console.log('==== Callstack after interaction call', this.getCallStack().print()); - console.log('==== PARENT Callstack after interaction call', this.parent()?.getCallStack().print()); + // console.log('==== Callstack after interaction call', this.getCallStack().print()); + // console.log('==== PARENT Callstack after interaction call', this.parent()?.getCallStack().print()); return result; } @@ -763,10 +784,6 @@ export class HandlerBasedContract implements Contract { return srcTx.id; } - get callingInteraction(): GQLNodeInterface | null { - return this._callingInteraction; - } - get rootSortKey(): string { return this._rootSortKey; } diff --git a/src/core/ContractCallStack.ts b/src/core/ContractCallStack.ts index 45b19c8..c41d4d4 100644 --- a/src/core/ContractCallStack.ts +++ b/src/core/ContractCallStack.ts @@ -1,15 +1,14 @@ import { InteractionData } from './modules/impl/HandlerExecutorFactory'; import { randomUUID } from 'crypto'; +import { InnerCallType } from '../contract/Contract'; export class ContractCallStack { readonly interactions: { [key: string]: InteractionCall } = {}; + readonly id: string; - constructor( - public readonly contractTxId: string, - public readonly depth: number, - public readonly label: string = '', - public readonly id = randomUUID() - ) {} + constructor(readonly contractTxId: string, readonly depth: number, readonly innerCallType: InnerCallType = null) { + this.id = randomUUID(); + } addInteractionData(interactionData: InteractionData): InteractionCall { const { interaction, interactionTx } = interactionData; @@ -38,7 +37,7 @@ export class ContractCallStack { } print(): string { - return JSON.stringify(this); + return JSON.stringify(this, null, 2); } } diff --git a/src/core/Warp.ts b/src/core/Warp.ts index 6833e04..59a72a1 100644 --- a/src/core/Warp.ts +++ b/src/core/Warp.ts @@ -1,6 +1,6 @@ import Arweave from 'arweave'; import { LevelDbCache } from '../cache/impl/LevelDbCache'; -import { Contract } from '../contract/Contract'; +import {Contract, InnerCallData, InnerCallType} from '../contract/Contract'; import { CreateContract } from '../contract/deploy/CreateContract'; import { DefaultCreateContract } from '../contract/deploy/impl/DefaultCreateContract'; import { HandlerBasedContract } from '../contract/HandlerBasedContract'; @@ -61,9 +61,9 @@ export class Warp { contract( contractTxId: string, callingContract?: Contract, - callingInteraction?: GQLNodeInterface + innerCallData?: InnerCallData ): Contract { - return new HandlerBasedContract(contractTxId, this, callingContract, callingInteraction); + return new HandlerBasedContract(contractTxId, this, callingContract, innerCallData); } /** diff --git a/src/core/modules/impl/DefaultStateEvaluator.ts b/src/core/modules/impl/DefaultStateEvaluator.ts index 1d52594..babc8a0 100644 --- a/src/core/modules/impl/DefaultStateEvaluator.ts +++ b/src/core/modules/impl/DefaultStateEvaluator.ts @@ -107,11 +107,10 @@ export abstract class DefaultStateEvaluator implements StateEvaluator { .addInteractionData({ interaction: null, interactionTx: missingInteraction, currentTx }); // creating a Contract instance for the "writing" contract - const writingContract = executionContext.warp.contract( - writingContractTxId, - executionContext.contract, - missingInteraction - ); + const writingContract = executionContext.warp.contract(writingContractTxId, executionContext.contract, { + callingInteraction: missingInteraction, + callType: 'read' + }); await this.onContractCall( missingInteraction, diff --git a/src/core/modules/impl/handler/AbstractContractHandler.ts b/src/core/modules/impl/handler/AbstractContractHandler.ts index 7a59267..0c4a82f 100644 --- a/src/core/modules/impl/handler/AbstractContractHandler.ts +++ b/src/core/modules/impl/handler/AbstractContractHandler.ts @@ -55,11 +55,10 @@ export abstract class AbstractContractHandler implements HandlerApi(input, this.swGlobal._activeTx, [ ...(currentTx || []), @@ -104,11 +103,10 @@ export abstract class AbstractContractHandler implements HandlerApi implements HandlerApi